Sources: add Gitea; fix Windows shims for shell tool, repo paths and stateDir
This commit is contained in:
+112
@@ -0,0 +1,112 @@
|
|||||||
|
# Gitea Source — design & decisions (working doc)
|
||||||
|
|
||||||
|
Goal per docs/008 Cookbook: new Source kind `gitea` (plugin `dsh-superagent-gitea`, files `src/sources/gitea*.ts`)
|
||||||
|
for the user's self-hosted **Gitea 1.25.4 at https://gitea.bitnasdaq.vip** (OpenAPI verified against the live
|
||||||
|
instance; dump at D:/Code/AI/gitea-spec.json). Gitea's REST API deliberately mirrors GitHub's shape
|
||||||
|
(`/notifications` + `X-Poll-Interval` header, Issue has `pull_request` meta, numbers shared Issue/PR sequence),
|
||||||
|
so `gitea.ts` mirrors `github.ts` structure with a direct HTTPS fetch client instead of the `gh` CLI.
|
||||||
|
|
||||||
|
## Domain map (cookbook step 1)
|
||||||
|
|
||||||
|
| Concept | Gitea mapping |
|
||||||
|
| --- | --- |
|
||||||
|
| Trigger | Issue body `key('gitea-issue', n)` (same format for PRs — shared number sequence); comment/review `key('gitea-comment', url||thread, id, updated_at)` |
|
||||||
|
| Thread | `String(number)` primary; full key `gitea:<number>` |
|
||||||
|
| Actor | `user.login` (allowed list names logins). Gitea 1.25.4 API has **no `is_bot` field** → `human: true` unless actor is the agent or on the Policy `bots` list (bots list is the lever). Muted/mirrored authors: `user` may be null → login `ghost`, id ''. |
|
||||||
|
| addressed | `mentions(body, login)` — same `@login` regex as github-trigger.ts |
|
||||||
|
| Channel address | `{ number, url }` — `url` = html_url of comment/issue, ALWAYS stored (cookbook: put every needed fact in the address) |
|
||||||
|
| Receiving scope rows | `issues` / `pulls` / `comments` (switches `observeIssues`/`observePulls`/`observeComments`, all default false). CI + inline review comments + PR-close binding = **M2, not MVP** |
|
||||||
|
|
||||||
|
## Config schema (stored in Settings.sources.gitea.config — generic record, zero core schema change)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{ url: '', // base, no trailing slash: ^https?://[^/]+$
|
||||||
|
repository: '', // owner/name
|
||||||
|
tokenEnv: 'GITEA_TOKEN', // NAME of env var holding the API token; token NEVER persisted (pattern: gh keeps creds out of config)
|
||||||
|
pollSeconds: 60, failuresBeforeError: 3,
|
||||||
|
observeIssues: false, observePulls: false, observeComments: false }
|
||||||
|
```
|
||||||
|
Token source: `process.env[tokenEnv]` in the **orchestrator host process** (DSH injects `~/.dsh/.credentials.yaml`
|
||||||
|
keys into process env → add `GITEA_TOKEN` there). Note: DSH strips credential-shaped env names from *worker*
|
||||||
|
children (infra/environment.ts header comment) — irrelevant, workers never need the token in MVP.
|
||||||
|
|
||||||
|
## Verified live endpoints (1.25.4)
|
||||||
|
|
||||||
|
- `GET /api/v1/notifications?all&since&limit&page` → `NotificationThread[]` {id, unread, repository{full_name},
|
||||||
|
subject{type,url,latest_comment_url,html_url,title,state}, updated_at}; header `X-Poll-Interval`.
|
||||||
|
- `GET /user` → {login,id}; `GET /repos/{o}/{r}` → {full_name, permissions{pull,push}}.
|
||||||
|
- `GET /repos/{o}/{r}/issues` (issues only), `/pulls` (PRs only) — unlike GitHub where /issues includes PRs!
|
||||||
|
Client-side filter updated_at >= cursor (server `since` support not trusted).
|
||||||
|
- `GET /repos/{o}/{r}/issues/{index}` → Issue with `pull_request` non-null when PR (same dual read as GitHub).
|
||||||
|
- `GET|POST /repos/{o}/{r}/issues/{index}/comments` (no `since` param — client filter on updated_at; pagination per_page=100&page=N until short page).
|
||||||
|
- `GET /repos/{o}/{r}/pulls/{index}/reviews` → PullReview[] {id,state,body,commit_id,user,html_url,submitted_at,updated_at}.
|
||||||
|
- **No repo-wide review-comment stream** (`/pulls/comments` MISSING in 1.25.4) → MVP review intake only via per-PR reads.
|
||||||
|
- CI for M2: `/repos/{o}/{r}/branches/{branch}` (user_can_push!), `/commits/{ref}/status` (CombinedStatus), `/actions/runs`.
|
||||||
|
|
||||||
|
## Poll flow (mirrors github.ts; MVP subset)
|
||||||
|
|
||||||
|
`pollRound` = 1) `pollNotifications` (KV gitea-since + per-number cursor KV `gitea-cursor:<n>` + retry map
|
||||||
|
`gitea-retry`; subject.url parsed by `giteaAddress` → read issue+comments[+reviews if PR]) →
|
||||||
|
2) `pollRepository` streams issues/pulls/comments (KV `gitea-stream:<id>`, cursorAt −5 s overlap, stable ids dedup,
|
||||||
|
horizon POLL_HORIZON_MS, `started` per kind) → 3) `pollThreads` subscribed threads via poll.ts `pollThreads`
|
||||||
|
(skip PR-close observation). 5 s test-timer-friendly `pause`. 3 consecutive round failures → health error
|
||||||
|
(`failuresBeforeError`). Own messages never Triggers. Health entry: `['gitea']`.
|
||||||
|
|
||||||
|
## Channel send (idempotent, GitHub pattern)
|
||||||
|
|
||||||
|
Marker `<!-- dsh-superagent:<message.id> -->` appended; before POST, list issue comments and if any comment by the
|
||||||
|
agent login contains the marker → 'sent'. POST `issues/{n}/comments` {body}. 'gone' when: GET issue → 404 (deleted) or
|
||||||
|
POST 403 `/locked/i`. Other throws → Outbox retries 5→300 s. `link()` = {url: address.url, label `Gitea #n`}.
|
||||||
|
`allowed(address, config)`: number parses && config.repository non-empty (address always has its own url).
|
||||||
|
|
||||||
|
## threadContext (assessments)
|
||||||
|
|
||||||
|
`messageId(trigger)`: issue body trigger → `${number}-body`; comment/review → `c${id}`/`r${id}`.
|
||||||
|
`read(part)`: fetch issue + all comments (page loop); messages = [body `${title}\n\n${body}`] + comments (+reviews
|
||||||
|
when PR and part allows), ContextMessage {id, from, at: updated_at??created_at, text, url}; 'root' → just body
|
||||||
|
message; flat forum — 'parent' returns the page containing the message (no threading in Gitea issue comments);
|
||||||
|
`next` = page cursor `p2` etc. body payload = the messages.
|
||||||
|
|
||||||
|
## Policy part
|
||||||
|
|
||||||
|
`allowed: [{id: login}]` regex `^[A-Za-z0-9._-]+$`; optional `bots` same shape (the substitute for missing is_bot —
|
||||||
|
documented hint: list automation accounts here). `explicit`: "名单里的账号在 Issue 或评论里 @ 智能体的账号。".
|
||||||
|
`rows`: issues/pulls/comments rows like github.ts (cells from observe* flags). No `sync` (self-hosted, no rosters).
|
||||||
|
|
||||||
|
## Environment variables (registered by this kind)
|
||||||
|
|
||||||
|
DSH_SUPERAGENT_GITEA_REPOSITORY (owner/name), DSH_SUPERAGENT_GITEA_URL (base url), DSH_SUPERAGENT_GITEA_ACCOUNT
|
||||||
|
(agent login — from deployment.gitea), DSH_SUPERAGENT_GITEA_NUMBER (issue/PR number that started ticket, else '').
|
||||||
|
No token vars in worker env ever.
|
||||||
|
|
||||||
|
## Core patch (required: `configured()` hard-requires gh — app/setup.ts resolve() → resolveDeployment throws)
|
||||||
|
|
||||||
|
1. `deployment.ts`: add `GiteaSetupSchema = {repository,url,tokenEnv}`, `SETUP_FIELDS.gitea`, `Deployment.gitea?:
|
||||||
|
{url, login}`; `resolveGiteaDeployment(machine, config, fetchImpl, env)`: url→GET /user (token env), repo→GET
|
||||||
|
/repos (canonical full_name + permissions.push required). Gitea-only Deployment shape:
|
||||||
|
`{ ...machine, repository: <gitea repo>, github: {cli:'',login:'',noreply:''}, gitea: {...} }` — github source
|
||||||
|
disabled in that profile, its readers never run (verified consumers: github.ts env/facts/poll only when enabled;
|
||||||
|
githubLink via registry per kind; writeEnvironment/verifyEnvironment patched next).
|
||||||
|
2. `app/setup.ts resolve()`: if github config empty (no repository && no cli) → gitea path; else GitHub path
|
||||||
|
unchanged. Gitea path: resolveGiteaDeployment; `writeEnvironment` branch when `!github.cli && gitea`: shims for
|
||||||
|
blocked commands (message "act as <gitea login> on Gitea"), NO gh shim, NO github.com credential section;
|
||||||
|
`verifyEnvironment` skipped for gitea-only (credential check is github.com-specific).
|
||||||
|
3. `checkAccounts`: block `source:gitea` → resolveGiteaDeployment (place 'gitea' so unrelated errors don't block saves).
|
||||||
|
4. `src/setup.ts` PROBLEMS: add gitea codes (place 'gitea'): giteaUrlUnset, giteaRepositoryUnset, giteaTokenMissing,
|
||||||
|
giteaTokenInvalid, giteaRepositoryUnreadable, giteaRepositoryReadOnly.
|
||||||
|
5. KV (`src/kv.ts`): `gitea: { since: record('gitea-since'), retries: record<Record<string,Retry>>('gitea-retry'),
|
||||||
|
cursor: family('gitea-cursor:'), stream: family('gitea-stream:') }` (tests/store.spec.ts requires key literals only there).
|
||||||
|
6. Build wiring: tsdown entry `'gitea-watch': 'src/sources/gitea.ts'`; package.json exports `"./gitea-watch"`;
|
||||||
|
cordis.patch.yml bundle insert `dsh-superagent-gitea` name `dsh-superagent/gitea-watch` **disabled: true**
|
||||||
|
(the router profile enables it; web profile must not). 007-architecture.md module+test tables (tests/docs.spec.ts
|
||||||
|
enforces completeness) + 001-domain "What is reusable" row. Tests: `tests/gitea.spec.ts` (fetch mocked via
|
||||||
|
injected fetchImpl / vi.stubGlobal), pattern of tests/sources.spec.ts.
|
||||||
|
|
||||||
|
## MVP cut lines (M2 later): Gitea PR observation + threadClosed/bindThread + auto-merge; inline review comments;
|
||||||
|
CI (branch + PR checks) triggers; agent-credential git helper for the Gitea host (MVP workers use the machine's
|
||||||
|
ambient git auth = user's own account for pushes; agent identity applies to API writes); prompt seeds that mention gh.
|
||||||
|
|
||||||
|
## Test plan order
|
||||||
|
translate → scope filtered → round (stored/filtered/cursor/dup) → mention explicit → send sent/gone/throw →
|
||||||
|
threadContext pages → apply registers + setupPending → policy rows → live smoke vs gitea.bitnasdaq.vip (needs user:
|
||||||
|
agent account + token + repo).
|
||||||
@@ -10,6 +10,10 @@
|
|||||||
- id: dsh-superagent-feishu
|
- id: dsh-superagent-feishu
|
||||||
name: dsh-superagent/feishu-watch
|
name: dsh-superagent/feishu-watch
|
||||||
disabled: true
|
disabled: true
|
||||||
|
# Optional Gitea Source; enable it and configure the instance URL, repository and token in Settings.
|
||||||
|
- id: dsh-superagent-gitea
|
||||||
|
name: dsh-superagent/gitea-watch
|
||||||
|
disabled: true
|
||||||
# The agent preset of every Executor Session (WORKER_PRESET in src/app/sessions.ts); the main Session has its own, below.
|
# The agent preset of every Executor Session (WORKER_PRESET in src/app/sessions.ts); the main Session has its own, below.
|
||||||
# It is DSH's `standard` preset with only what a worker needs, and a shell whose foreground command moves to a background job
|
# It is DSH's `standard` preset with only what a worker needs, and a shell whose foreground command moves to a background job
|
||||||
# after 5 seconds, so a long command does not hold back a person's message, which the Session takes at its next step. Left
|
# after 5 seconds, so a long command does not hold back a person's message, which the Session takes at its next step. Left
|
||||||
|
|||||||
@@ -372,6 +372,10 @@ Some generic modules still refer directly to GitHub or Feishu:
|
|||||||
| `src/sources/feishu-loops.ts` | The Feishu loops: the event consumer, history recovery, chat names and polling of subscribed topics |
|
| `src/sources/feishu-loops.ts` | The Feishu loops: the event consumer, history recovery, chat names and polling of subscribed topics |
|
||||||
| `src/sources/feishu-channel.ts` | The Feishu Channel: topic replies, `sent` or `gone` |
|
| `src/sources/feishu-channel.ts` | The Feishu Channel: topic replies, `sent` or `gone` |
|
||||||
| `src/sources/feishu-read.ts` | The `feishu/` reads of a Trigger's message, its root, parent and topic, and `feishuThreadContext`, which gives them to assessments with their images |
|
| `src/sources/feishu-read.ts` | The `feishu/` reads of a Trigger's message, its root, parent and topic, and `feishuThreadContext`, which gives them to assessments with their images |
|
||||||
|
| `src/sources/gitea.ts` | The Gitea kind and plugin entry, exported as `./gitea-watch`: a poll round of notifications, the observed repository streams, and subscribed Threads |
|
||||||
|
| `src/sources/gitea-api.ts` | The minimal Gitea REST client: token auth from a named environment variable, zod-validated answers, pagination, an injectable fetch |
|
||||||
|
| `src/sources/gitea-trigger.ts` | Gitea Issue bodies, comments and reviews as Triggers, and Gitea addresses |
|
||||||
|
| `src/sources/gitea-channel.ts` | The Gitea Channel: Issue and PR comments, `sent` or `gone` |
|
||||||
| `src/sources/admission-context.ts` | The bounded window an assessment starts from: the first pages of a Trigger's Thread, read through its kind, and the messages around the Trigger |
|
| `src/sources/admission-context.ts` | The bounded window an assessment starts from: the first pages of a Trigger's Thread, read through its kind, and the messages around the Trigger |
|
||||||
| `src/sources/feishu-image.ts` | Reads a Feishu message's image as an attachment for the assessing Session |
|
| `src/sources/feishu-image.ts` | Reads a Feishu message's image as an attachment for the assessing Session |
|
||||||
| `src/client/index.tsx` | The client plugin entry: the sidebar entry, the main panel and the page in DeepSeek Harness's Plugin Manager |
|
| `src/client/index.tsx` | The client plugin entry: the sidebar entry, the main panel and the page in DeepSeek Harness's Plugin Manager |
|
||||||
@@ -397,7 +401,7 @@ Some generic modules still refer directly to GitHub or Feishu:
|
|||||||
| `src/client/locales.ts` | All copy in both languages, and the glossary of [001 Domain model](001-domain.md) |
|
| `src/client/locales.ts` | All copy in both languages, and the glossary of [001 Domain model](001-domain.md) |
|
||||||
| `src/client/client.css`, `css.d.ts` | The panel's stylesheet, loaded as text |
|
| `src/client/client.css`, `css.d.ts` | The panel's stylesheet, loaded as text |
|
||||||
|
|
||||||
`src/sources/github.ts` and `src/sources/feishu.ts` are also the entries of the two Source plugins, built by `tsdown.config.ts` and exported in `package.json` as `./github-watch` and `./feishu-watch`. The bundle's `cordis.patch.yml` inserts the three plugins, with Feishu disabled.
|
`src/sources/github.ts`, `src/sources/feishu.ts` and `src/sources/gitea.ts` are also the entries of the three Source plugins, built by `tsdown.config.ts` and exported in `package.json` as `./github-watch`, `./feishu-watch` and `./gitea-watch`. The bundle's `cordis.patch.yml` inserts the four plugins, with Feishu and Gitea disabled.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -408,6 +412,7 @@ Some generic modules still refer directly to GitHub or Feishu:
|
|||||||
| `tests/policy.spec.ts` | Source scope, the gate, the admission table, Mandate revalidation, the Policy version, settings migrations and assessment outcomes |
|
| `tests/policy.spec.ts` | Source scope, the gate, the admission table, Mandate revalidation, the Policy version, settings migrations and assessment outcomes |
|
||||||
| `tests/registry.spec.ts` | `SourceRegistry`: instances, validation, Policy parts, Channels and Thread context |
|
| `tests/registry.spec.ts` | `SourceRegistry`: instances, validation, Policy parts, Channels and Thread context |
|
||||||
| `tests/sources.spec.ts` | The Feishu and GitHub Sources on a `SourceContext`: translation, polls and read positions, the pull request rules, health |
|
| `tests/sources.spec.ts` | The Feishu and GitHub Sources on a `SourceContext`: translation, polls and read positions, the pull request rules, health |
|
||||||
|
| `tests/gitea.spec.ts` | The Gitea Source on a `SourceContext` against a fetched stub of the Gitea API: translation, notification and stream polls, cursors, the Channel, health |
|
||||||
| `tests/commands.spec.ts` | CLI calls as the deployment's accounts, the Channels' answers, and repository-scoped GitHub calls |
|
| `tests/commands.spec.ts` | CLI calls as the deployment's accounts, the Channels' answers, and repository-scoped GitHub calls |
|
||||||
| `tests/feishu-read.spec.ts`, `tests/github-read.spec.ts` | Feishu and GitHub reads |
|
| `tests/feishu-read.spec.ts`, `tests/github-read.spec.ts` | Feishu and GitHub reads |
|
||||||
| `tests/admission-context.spec.ts`, `tests/feishu-image.spec.ts` | The Thread window of assessments, and Feishu image reads |
|
| `tests/admission-context.spec.ts`, `tests/feishu-image.spec.ts` | The Thread window of assessments, and Feishu image reads |
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"./client": "./lib/client.js",
|
"./client": "./lib/client.js",
|
||||||
"./github-watch": "./lib/github-watch.js",
|
"./github-watch": "./lib/github-watch.js",
|
||||||
"./feishu-watch": "./lib/feishu-watch.js",
|
"./feishu-watch": "./lib/feishu-watch.js",
|
||||||
|
"./gitea-watch": "./lib/gitea-watch.js",
|
||||||
"./package.json": "./package.json",
|
"./package.json": "./package.json",
|
||||||
"./locale/*.json": "./locale/*.json"
|
"./locale/*.json": "./locale/*.json"
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-3
@@ -58,7 +58,8 @@ declare module '@deepseek-ai/cordis' {
|
|||||||
* changes the profile, schedules work or starts goals outside the worker slots, the pause and a person's hold, starts or
|
* changes the profile, schedules work or starts goals outside the worker slots, the pause and a person's hold, starts or
|
||||||
* messages other Sessions, or edits documents.
|
* messages other Sessions, or edits documents.
|
||||||
*/
|
*/
|
||||||
const WORK_TOOLS = ['read', 'write', 'edit', 'read_image', 'glob', 'grep', 'bash', 'pwsh', 'job_output', 'job_list', 'job_kill', 'web_search',
|
export const SHELL_TOOL = process.platform === 'win32' ? 'pwsh' : 'bash'
|
||||||
|
const WORK_TOOLS = ['read', 'write', 'edit', 'read_image', 'glob', 'grep', SHELL_TOOL, 'job_output', 'job_list', 'job_kill', 'web_search',
|
||||||
'web_fetch', 'skill', 'todo_write', 'subagent', 'list_subagent_models', 'send_message', 'list_agents', 'interrupt_agent']
|
'web_fetch', 'skill', 'todo_write', 'subagent', 'list_subagent_models', 'send_message', 'list_agents', 'interrupt_agent']
|
||||||
/** What the main Session is told when it would wait: what it waits for comes to it, and a waiting step holds back the next. */
|
/** What the main Session is told when it would wait: what it waits for comes to it, and a waiting step holds back the next. */
|
||||||
const NO_WAIT = 'Reports, follow_up notices, new candidates and a background job\'s end with its output arrive by themselves as messages; '
|
const NO_WAIT = 'Reports, follow_up notices, new candidates and a background job\'s end with its output arrive by themselves as messages; '
|
||||||
@@ -76,7 +77,7 @@ const SLEEP = /(?:^|[;&|({!`\n]|\b(?:do|then|else|elif|if|while|until|time|exec|
|
|||||||
*/
|
*/
|
||||||
function noWait({ name, arguments: input }: { name: string; arguments: unknown }, routed: () => boolean): string | undefined {
|
function noWait({ name, arguments: input }: { name: string; arguments: unknown }, routed: () => boolean): string | undefined {
|
||||||
const args = (input ?? {}) as { command?: unknown; timeoutMs?: unknown; run_in_background?: unknown; wait?: unknown; timeout_ms?: unknown }
|
const args = (input ?? {}) as { command?: unknown; timeoutMs?: unknown; run_in_background?: unknown; wait?: unknown; timeout_ms?: unknown }
|
||||||
if (name === 'bash') {
|
if (name === 'bash' || name === 'pwsh') {
|
||||||
const sleeps = typeof args.command === 'string' && SLEEP.test(args.command)
|
const sleeps = typeof args.command === 'string' && SLEEP.test(args.command)
|
||||||
if (sleeps || typeof args.timeoutMs === 'number' && args.timeoutMs > 30_000) return NO_WAIT
|
if (sleeps || typeof args.timeoutMs === 'number' && args.timeoutMs > 30_000) return NO_WAIT
|
||||||
if (args.timeoutMs === undefined && args.run_in_background !== true && !routed()) return DEFAULT_WAIT
|
if (args.timeoutMs === undefined && args.run_in_background !== true && !routed()) return DEFAULT_WAIT
|
||||||
@@ -260,7 +261,7 @@ export class Sessions {
|
|||||||
const register = this.registerTool(agent, binding)
|
const register = this.registerTool(agent, binding)
|
||||||
for (const tool of host.tools(agent, ticket)) register(tool.name, tool.description, tool.schema, tool.execute)
|
for (const tool of host.tools(agent, ticket)) register(tool.name, tool.description, tool.schema, tool.execute)
|
||||||
if (master) {
|
if (master) {
|
||||||
binding.disposers.push(agent.ctx.tools.restrict({ allow: ['skill', 'bash', 'read', 'read_image', 'job_output', 'job_list', 'job_kill'] }),
|
binding.disposers.push(agent.ctx.tools.restrict({ allow: ['skill', SHELL_TOOL, 'read', 'read_image', 'job_output', 'job_list', 'job_kill'] }),
|
||||||
agent.ctx.tools.guard(execution => noWait(execution, () => this.routed(agent))))
|
agent.ctx.tools.guard(execution => noWait(execution, () => this.routed(agent))))
|
||||||
} else {
|
} else {
|
||||||
// An Executor Session keeps the profile's work tools it has, which DSH requires to be known; its own tools are not masked.
|
// An Executor Session keeps the profile's work tools it has, which DSH requires to be known; its own tools are not masked.
|
||||||
|
|||||||
+7
-3
@@ -1,7 +1,7 @@
|
|||||||
/** Probes repository and account CLIs, configures the worker execution environment and command shims, and tracks setup readiness. */
|
/** Probes repository and account CLIs, configures the worker execution environment and command shims, and tracks setup readiness. */
|
||||||
import type { Context } from '@deepseek-ai/cordis'
|
import type { Context } from '@deepseek-ai/cordis'
|
||||||
import { UserError } from '../core/index.ts'
|
import { UserError } from '../core/index.ts'
|
||||||
import { SETUP_FIELDS, resolveDeployment, resolveFeishuBot, workerProbe, type Deployment, type Machine } from '../deployment.ts'
|
import { SETUP_FIELDS, giteaOnly, resolveDeployment, resolveFeishuBot, resolveGiteaDeployment, workerProbe, type Deployment, type Machine } from '../deployment.ts'
|
||||||
import { agentVariables, applyEnvironment, verifyEnvironment, withoutDirectory, workerBin, writeEnvironment } from '../infra/environment.ts'
|
import { agentVariables, applyEnvironment, verifyEnvironment, withoutDirectory, workerBin, writeEnvironment } from '../infra/environment.ts'
|
||||||
import type { Settings, SettingsBlock } from '../policy.ts'
|
import type { Settings, SettingsBlock } from '../policy.ts'
|
||||||
import { SetupError, type SetupProblem } from '../setup.ts'
|
import { SetupError, type SetupProblem } from '../setup.ts'
|
||||||
@@ -61,6 +61,7 @@ export class Setup {
|
|||||||
async checkAccounts(block: SettingsBlock, settings: Settings): Promise<void> {
|
async checkAccounts(block: SettingsBlock, settings: Settings): Promise<void> {
|
||||||
const { machine } = this.host
|
const { machine } = this.host
|
||||||
const check = block === 'source:feishu' ? resolveFeishuBot(machine, settings.sources.feishu?.config, this.probe())
|
const check = block === 'source:feishu' ? resolveFeishuBot(machine, settings.sources.feishu?.config, this.probe())
|
||||||
|
: block === 'source:gitea' ? resolveGiteaDeployment(machine, settings.sources.gitea?.config)
|
||||||
: resolveDeployment(machine, settings.sources.github?.config, this.probe())
|
: resolveDeployment(machine, settings.sources.github?.config, this.probe())
|
||||||
await check.catch((error: unknown) => { if (!(error instanceof SetupError) || error.place === block.slice(7)) throw error })
|
await check.catch((error: unknown) => { if (!(error instanceof SetupError) || error.place === block.slice(7)) throw error })
|
||||||
}
|
}
|
||||||
@@ -76,7 +77,10 @@ export class Setup {
|
|||||||
private async resolve(settings: Settings): Promise<Deployment> {
|
private async resolve(settings: Settings): Promise<Deployment> {
|
||||||
const { context, machine, lifetime } = this.host
|
const { context, machine, lifetime } = this.host
|
||||||
const probe = this.probe()
|
const probe = this.probe()
|
||||||
const deployment = await resolveDeployment(machine, settings.sources.github?.config, probe)
|
// A deployment runs on one forge: with GitHub entirely unset and Gitea filled in, the Gitea Source resolves the accounts.
|
||||||
|
const deployment = giteaOnly(settings.sources.github?.config, settings.sources.gitea?.config)
|
||||||
|
? await resolveGiteaDeployment(machine, settings.sources.gitea?.config)
|
||||||
|
: await resolveDeployment(machine, settings.sources.github?.config, probe)
|
||||||
const feishu = await resolveFeishuBot(machine, settings.sources.feishu?.config, probe).catch((error: unknown) => {
|
const feishu = await resolveFeishuBot(machine, settings.sources.feishu?.config, probe).catch((error: unknown) => {
|
||||||
if (!(error instanceof SetupError)) throw error
|
if (!(error instanceof SetupError)) throw error
|
||||||
this.feishuProblem = error.problem
|
this.feishuProblem = error.problem
|
||||||
@@ -88,7 +92,7 @@ export class Setup {
|
|||||||
if (error instanceof SetupError) throw error
|
if (error instanceof SetupError) throw error
|
||||||
throw new SetupError('environmentFailed', { output: error instanceof Error ? error.message : String(error) })
|
throw new SetupError('environmentFailed', { output: error instanceof Error ? error.message : String(error) })
|
||||||
}
|
}
|
||||||
await writeEnvironment(resolved, settings.executor.blockedCommands).catch(machineProblem)
|
await writeEnvironment(resolved, settings.executor.blockedCommands, process.env).catch(machineProblem)
|
||||||
this.restoreEnvironment = applyEnvironment(this.variables, machine.stateDir, this.path)
|
this.restoreEnvironment = applyEnvironment(this.variables, machine.stateDir, this.path)
|
||||||
await verifyEnvironment(resolved, context.subprocess, lifetime).catch((error: unknown) => {
|
await verifyEnvironment(resolved, context.subprocess, lifetime).catch((error: unknown) => {
|
||||||
this.restore()
|
this.restore()
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ const TEXT = {
|
|||||||
'problemSetupFix.github': ['在设置 › 来源的 GitHub 卡片修正后保存,会重新检查;在 dsh-superagent 之外修复的(例如登录 CLI),点“重新检查”。', 'Fix it on the GitHub card of the Source settings and save to check again; after a fix outside dsh-superagent, such as signing a CLI in, choose Check again.'],
|
'problemSetupFix.github': ['在设置 › 来源的 GitHub 卡片修正后保存,会重新检查;在 dsh-superagent 之外修复的(例如登录 CLI),点“重新检查”。', 'Fix it on the GitHub card of the Source settings and save to check again; after a fix outside dsh-superagent, such as signing a CLI in, choose Check again.'],
|
||||||
'problemSetupFix.feishu': ['在设置 › 来源的飞书卡片修正后保存,会重新检查;在 dsh-superagent 之外修复的(例如配置机器人),点“重新检查”。', 'Fix it on the Feishu card of the Source settings and save to check again; after a fix outside dsh-superagent, such as configuring the bot, choose Check again.'],
|
'problemSetupFix.feishu': ['在设置 › 来源的飞书卡片修正后保存,会重新检查;在 dsh-superagent 之外修复的(例如配置机器人),点“重新检查”。', 'Fix it on the Feishu card of the Source settings and save to check again; after a fix outside dsh-superagent, such as configuring the bot, choose Check again.'],
|
||||||
'problemSetupFix.deployment': ['在部署设置里修正后保存,会重新检查;在 dsh-superagent 之外修复的(例如升级 git),点“重新检查”。', 'Fix it in the Deployment settings and save to check again; after a fix outside dsh-superagent, such as upgrading git, choose Check again.'],
|
'problemSetupFix.deployment': ['在部署设置里修正后保存,会重新检查;在 dsh-superagent 之外修复的(例如升级 git),点“重新检查”。', 'Fix it in the Deployment settings and save to check again; after a fix outside dsh-superagent, such as upgrading git, choose Check again.'],
|
||||||
|
'problemSetupFix.gitea': ['在设置 › 来源的 Gitea 卡片修正后保存,会重新检查;在 dsh-superagent 之外修复的(例如给令牌),点“重新检查”。', 'Fix it on the Gitea card of the Source settings and save to check again; after a fix outside dsh-superagent, such as setting the token, choose Check again.'],
|
||||||
problemSetupFeishu: ['飞书机器人检查未通过,飞书来源暂停', 'Feishu bot check failed; the Feishu Source is paused'],
|
problemSetupFeishu: ['飞书机器人检查未通过,飞书来源暂停', 'Feishu bot check failed; the Feishu Source is paused'],
|
||||||
// Source
|
// Source
|
||||||
labelsAdded: ['列表已更新,新增:{names}', 'List updated, added: {names}'],
|
labelsAdded: ['列表已更新,新增:{names}', 'List updated, added: {names}'],
|
||||||
@@ -741,6 +742,14 @@ const TEXT = {
|
|||||||
'setup.feishuBotNotReady': ['应用 {app} 的机器人身份状态是 {status};用 lark-cli 配置应用密钥。', SETUP_PROBLEMS.feishuBotNotReady[1]],
|
'setup.feishuBotNotReady': ['应用 {app} 的机器人身份状态是 {status};用 lark-cli 配置应用密钥。', SETUP_PROBLEMS.feishuBotNotReady[1]],
|
||||||
'setup.feishuUserIdentity': ['机器人的 lark-cli 允许用户身份(strict mode 为 {mode});按 docs/006-deployment.md 给机器人一份 strict mode 为 bot 的单独配置。', SETUP_PROBLEMS.feishuUserIdentity[1]],
|
'setup.feishuUserIdentity': ['机器人的 lark-cli 允许用户身份(strict mode 为 {mode});按 docs/006-deployment.md 给机器人一份 strict mode 为 bot 的单独配置。', SETUP_PROBLEMS.feishuUserIdentity[1]],
|
||||||
'setup.feishuNoBot': ['飞书机器人接口没有返回机器人的 open_id。', SETUP_PROBLEMS.feishuNoBot[1]],
|
'setup.feishuNoBot': ['飞书机器人接口没有返回机器人的 open_id。', SETUP_PROBLEMS.feishuNoBot[1]],
|
||||||
|
'setup.giteaCredentialFailed': ['git 没有读取 dsh-superagent 为 Gitea 写的凭据配置。', SETUP_PROBLEMS.giteaCredentialFailed[1]],
|
||||||
|
'setup.giteaUrlUnset': ['Gitea 来源没有设置实例地址,例如 https://gitea.example.com。', SETUP_PROBLEMS.giteaUrlUnset[1]],
|
||||||
|
'setup.giteaRepositoryUnset': ['Gitea 来源没有设置监听的仓库。', SETUP_PROBLEMS.giteaRepositoryUnset[1]],
|
||||||
|
'setup.giteaTokenMissing': ['存放智能体 Gitea 令牌的环境变量 {name} 没有取值。', SETUP_PROBLEMS.giteaTokenMissing[1]],
|
||||||
|
'setup.giteaAccountUnreadable': ['读不到 {name} 背后的 Gitea 账号。', SETUP_PROBLEMS.giteaAccountUnreadable[1]],
|
||||||
|
'setup.giteaRepositoryUnreadable': ['@{account} 读不到 {repository}。', SETUP_PROBLEMS.giteaRepositoryUnreadable[1]],
|
||||||
|
'setup.giteaRepositoryUnreported': ['Gitea 没有返回 {repository}。', SETUP_PROBLEMS.giteaRepositoryUnreported[1]],
|
||||||
|
'setup.giteaRepositoryReadOnly': ['@{account} 没有 {repository} 的推送权限。', SETUP_PROBLEMS.giteaRepositoryReadOnly[1]],
|
||||||
'healthCode.setupPending': ['等待部署检查', HEALTH_TEXT.setupPending],
|
'healthCode.setupPending': ['等待部署检查', HEALTH_TEXT.setupPending],
|
||||||
'healthCode.sourceOff': ['已关闭', HEALTH_TEXT.sourceOff],
|
'healthCode.sourceOff': ['已关闭', HEALTH_TEXT.sourceOff],
|
||||||
'healthCode.feishuCliUnset': ['来源已打开,还没有设置机器人的 lark-cli', HEALTH_TEXT.feishuCliUnset],
|
'healthCode.feishuCliUnset': ['来源已打开,还没有设置机器人的 lark-cli', HEALTH_TEXT.feishuCliUnset],
|
||||||
|
|||||||
+42
-2
@@ -7,10 +7,11 @@ import s from '@deepseek-ai/schemastery'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
|
import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
|
||||||
import { runAsWorker, sameDirectory, withoutDirectory, workerBin } from './infra/environment.ts'
|
import { runAsWorker, sameDirectory, withoutDirectory, workerBin } from './infra/environment.ts'
|
||||||
|
import { giteaClient, type GiteaFetch } from './sources/gitea-api.ts'
|
||||||
import { SetupError } from './setup.ts'
|
import { SetupError } from './setup.ts'
|
||||||
|
|
||||||
const reason = (error: unknown): string => error instanceof Error ? error.message : String(error)
|
const reason = (error: unknown): string => error instanceof Error ? error.message : String(error)
|
||||||
const absolute = (description: string) => s.string().pattern(/^\//).description(`${description} Absolute path.`)
|
const absolute = (description: string) => s.string().pattern(/^(?:\/|[A-Za-z]:[\\/]|\\\\)/).description(`${description} Absolute path.`)
|
||||||
/** Plugin configuration of the dsh-superagent entry. Volatile fields apply without a restart because dsh-superagent reads them on each use; changing `stateDir` remounts the plugin. */
|
/** Plugin configuration of the dsh-superagent entry. Volatile fields apply without a restart because dsh-superagent reads them on each use; changing `stateDir` remounts the plugin. */
|
||||||
export const Config = s.object({
|
export const Config = s.object({
|
||||||
stateDir: absolute('Directory for the database, locks, worker shims, the agent\'s git configuration and, by default, the ticket directories.').required(),
|
stateDir: absolute('Directory for the database, locks, worker shims, the agent\'s git configuration and, by default, the ticket directories.').required(),
|
||||||
@@ -46,15 +47,31 @@ export const GithubAccountsSchema = z.object({
|
|||||||
repository: z.string().trim().regex(/^([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)?$/, 'owner/name').default(''),
|
repository: z.string().trim().regex(/^([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)?$/, 'owner/name').default(''),
|
||||||
cli: z.string().trim().default(''),
|
cli: z.string().trim().default(''),
|
||||||
})
|
})
|
||||||
|
/** The Gitea Source fields that drive deployment setup: the platform, its repository, and the name of the token variable. */
|
||||||
|
export const GiteaSetupSchema = z.object({
|
||||||
|
repository: z.string().trim().regex(/^([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)?$/, 'owner/name').default(''),
|
||||||
|
url: z.string().trim().regex(/^(https?:\/\/[^/]+)?$/, 'http(s)://host').default(''),
|
||||||
|
tokenEnv: z.string().trim().regex(/^[A-Za-z_][A-Za-z0-9_]*$/).default('GITEA_TOKEN'),
|
||||||
|
})
|
||||||
export const FeishuBotSchema = z.object({ cli: z.string().trim().default('') })
|
export const FeishuBotSchema = z.object({ cli: z.string().trim().default('') })
|
||||||
/** Source configuration keys that affect deployment setup and trigger a restart when changed. */
|
/** Source configuration keys that affect deployment setup and trigger a restart when changed. */
|
||||||
export const SETUP_FIELDS: Readonly<Record<string, readonly string[]>> = { github: Object.keys(GithubAccountsSchema.shape), feishu: Object.keys(FeishuBotSchema.shape) }
|
export const SETUP_FIELDS: Readonly<Record<string, readonly string[]>> = { github: Object.keys(GithubAccountsSchema.shape),
|
||||||
|
gitea: Object.keys(GiteaSetupSchema.shape), feishu: Object.keys(FeishuBotSchema.shape) }
|
||||||
|
|
||||||
export interface Deployment extends Machine {
|
export interface Deployment extends Machine {
|
||||||
repository: string
|
repository: string
|
||||||
github: { cli: string; login: string; noreply: string }
|
github: { cli: string; login: string; noreply: string }
|
||||||
|
/** Present on a Gitea-only deployment: GitHub is off and the agent's forge account lives on this Gitea. */
|
||||||
|
gitea?: GiteaAccount
|
||||||
feishu?: FeishuBot
|
feishu?: FeishuBot
|
||||||
}
|
}
|
||||||
|
export interface GiteaAccount { url: string; tokenEnv: string; login: string }
|
||||||
|
/** True when GitHub is entirely unset and the Gitea Source names a complete platform + repository: the deployment runs on Gitea. */
|
||||||
|
export function giteaOnly(github: unknown, gitea: unknown): boolean {
|
||||||
|
const gh = GithubAccountsSchema.parse(github ?? {})
|
||||||
|
const gt = GiteaSetupSchema.parse(gitea ?? {})
|
||||||
|
return !gh.repository && !gh.cli && !!gt.url && !!gt.repository
|
||||||
|
}
|
||||||
export interface FeishuBot { cli: string; appId: string; brand: 'feishu' | 'lark'; botId: string; name: string }
|
export interface FeishuBot { cli: string; appId: string; brand: 'feishu' | 'lark'; botId: string; name: string }
|
||||||
|
|
||||||
/** Resolve a command name or absolute path to an executable file, ignoring the worker shim directory. */
|
/** Resolve a command name or absolute path to an executable file, ignoring the worker shim directory. */
|
||||||
@@ -104,6 +121,29 @@ export async function resolveDeployment(machine: Machine, github: unknown, probe
|
|||||||
if (access.push !== true) throw new SetupError('repositoryReadOnly', { account: agent.login, repository })
|
if (access.push !== true) throw new SetupError('repositoryReadOnly', { account: agent.login, repository })
|
||||||
return { ...machine, repository, github: { cli, login: agent.login, noreply: `${agent.id}+${agent.login}@users.noreply.github.com` } }
|
return { ...machine, repository, github: { cli, login: agent.login, noreply: `${agent.id}+${agent.login}@users.noreply.github.com` } }
|
||||||
}
|
}
|
||||||
|
const GiteaUser = z.looseObject({ login: z.string().regex(/^[A-Za-z0-9_.-]+$/) })
|
||||||
|
const GiteaRepository = z.looseObject({ full_name: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/),
|
||||||
|
permissions: z.looseObject({ push: z.boolean().optional() }) })
|
||||||
|
/**
|
||||||
|
* Verify the agent's Gitea account (a token in `env[tokenEnv]`) and push access to the named repository, the way
|
||||||
|
* `resolveDeployment` does with `gh`. Used on a Gitea-only deployment; reads go straight to the platform over HTTPS.
|
||||||
|
*/
|
||||||
|
export async function resolveGiteaDeployment(machine: Machine, gitea: unknown, env: Record<string, string | undefined> = process.env,
|
||||||
|
fetchImpl: GiteaFetch = fetch): Promise<Deployment> {
|
||||||
|
const config = GiteaSetupSchema.parse(gitea ?? {})
|
||||||
|
if (!config.url) throw new SetupError('giteaUrlUnset')
|
||||||
|
if (!config.repository) throw new SetupError('giteaRepositoryUnset')
|
||||||
|
if (!env[config.tokenEnv]) throw new SetupError('giteaTokenMissing', { name: config.tokenEnv })
|
||||||
|
const api = giteaClient(config.url, config.tokenEnv, env, fetchImpl)
|
||||||
|
const account = await api.json(GiteaUser, 'user')
|
||||||
|
.catch((error: unknown) => { throw new SetupError('giteaAccountUnreadable', { name: config.tokenEnv, output: reason(error) }) })
|
||||||
|
// The canonical full name from the platform matches incoming notifications even after renames, as on GitHub.
|
||||||
|
const repository = await api.json(GiteaRepository, `repos/${config.repository}`)
|
||||||
|
.catch((error: unknown) => { throw new SetupError('giteaRepositoryUnreadable', { account: account.login, repository: config.repository, output: reason(error) }) })
|
||||||
|
if (repository.permissions.push !== true) throw new SetupError('giteaRepositoryReadOnly', { account: account.login, repository: repository.full_name })
|
||||||
|
return { ...machine, repository: repository.full_name, github: { cli: '', login: '', noreply: '' },
|
||||||
|
gitea: { url: config.url.replace(/\/+$/, ''), tokenEnv: config.tokenEnv, login: account.login } }
|
||||||
|
}
|
||||||
|
|
||||||
const AuthStatus = z.object({ appId: z.string().regex(/^cli_[A-Za-z0-9]+$/), brand: z.enum(['feishu', 'lark']).default('feishu'), identities: z.object({ bot: z.object({ status: z.string() }) }) })
|
const AuthStatus = z.object({ appId: z.string().regex(/^cli_[A-Za-z0-9]+$/), brand: z.enum(['feishu', 'lark']).default('feishu'), identities: z.object({ bot: z.object({ status: z.string() }) }) })
|
||||||
const BotInfo = z.object({ bot: z.object({ open_id: z.string().regex(/^ou_[A-Za-z0-9]+$/), app_name: z.string().default('') }) })
|
const BotInfo = z.object({ bot: z.object({ open_id: z.string().regex(/^ou_[A-Za-z0-9]+$/), app_name: z.string().default('') }) })
|
||||||
|
|||||||
+43
-11
@@ -7,7 +7,7 @@ import { lstat, mkdir, readdir, readFile, rename, unlink, writeFile } from 'node
|
|||||||
import { realpathSync } from 'node:fs'
|
import { realpathSync } from 'node:fs'
|
||||||
import { delimiter, join, resolve } from 'node:path'
|
import { delimiter, join, resolve } from 'node:path'
|
||||||
import type { SubprocessOutputReader, SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
|
import type { SubprocessOutputReader, SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
|
||||||
import type { Deployment } from '../deployment.ts'
|
import type { Deployment, GiteaAccount } from '../deployment.ts'
|
||||||
import { SetupError } from '../setup.ts'
|
import { SetupError } from '../setup.ts'
|
||||||
import { CliError } from './commands.ts'
|
import { CliError } from './commands.ts'
|
||||||
|
|
||||||
@@ -21,6 +21,21 @@ export const workerGitConfig = (stateDir: string): string => join(stateDir, 'wor
|
|||||||
const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`
|
const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`
|
||||||
const gitQuote = (value: string): string => `"${value.replace(/[\\"]/g, char => `\\${char}`)}"`
|
const gitQuote = (value: string): string => `"${value.replace(/[\\"]/g, char => `\\${char}`)}"`
|
||||||
export const credentialHelper = (cli: string): string => `!${shellQuote(cli)} auth git-credential`
|
export const credentialHelper = (cli: string): string => `!${shellQuote(cli)} auth git-credential`
|
||||||
|
/** Token store for a Gitea deployment: one `https://login:token@host` line, read by git's built-in store helper. */
|
||||||
|
export const giteaCredentialsPath = (stateDir: string): string => join(stateDir, 'gitea-git-credentials')
|
||||||
|
/** The built-in `store` helper avoids any shell script, so it works identically on Windows git. */
|
||||||
|
export const giteaCredentialHelper = (stateDir: string): string =>
|
||||||
|
`store --file=${giteaCredentialsPath(stateDir).replaceAll('\\', '/')}`
|
||||||
|
/** Writes the agent's Gitea token (mode 0600, atomic rename) for the store helper; the token never enters git config itself. */
|
||||||
|
async function writeGiteaCredentials(file: string, gitea: GiteaAccount,
|
||||||
|
env: Record<string, string | undefined>): Promise<void> {
|
||||||
|
const token = env[gitea.tokenEnv] ?? ''
|
||||||
|
if (!token) throw new SetupError('giteaTokenMissing', { name: gitea.tokenEnv })
|
||||||
|
const host = new URL(gitea.url).host
|
||||||
|
const temporary = `${file}.${process.pid}.tmp`
|
||||||
|
await writeFile(temporary, `https://${encodeURIComponent(gitea.login)}:${encodeURIComponent(token)}@${host}\n`, { mode: 0o600 })
|
||||||
|
await rename(temporary, file)
|
||||||
|
}
|
||||||
|
|
||||||
export function sameDirectory(a: string, b: string): boolean {
|
export function sameDirectory(a: string, b: string): boolean {
|
||||||
const real = (path: string): string => { try { return realpathSync(path) } catch { return resolve(path) } }
|
const real = (path: string): string => { try { return realpathSync(path) } catch { return resolve(path) } }
|
||||||
@@ -76,8 +91,11 @@ async function own(file: string, text: string, mode = 0o644): Promise<void> {
|
|||||||
* Atomically writes worker CLI shims and the isolated git configuration file into the state directory, refusing to overwrite foreign files.
|
* Atomically writes worker CLI shims and the isolated git configuration file into the state directory, refusing to overwrite foreign files.
|
||||||
* Each shim strips the shim directory from PATH before exec so wrapper scripts resolving `gh` or `lark-cli` by name reach the real binary.
|
* Each shim strips the shim directory from PATH before exec so wrapper scripts resolving `gh` or `lark-cli` by name reach the real binary.
|
||||||
*/
|
*/
|
||||||
export async function writeEnvironment(deployment: Pick<Deployment, 'stateDir' | 'repository' | 'github' | 'feishu'>, blockedCommands: readonly string[]): Promise<void> {
|
export async function writeEnvironment(deployment: Pick<Deployment, 'stateDir' | 'repository' | 'github' | 'feishu' | 'gitea'>, blockedCommands: readonly string[],
|
||||||
const { stateDir, repository, github, feishu } = deployment
|
env: Record<string, string | undefined> = process.env): Promise<void> {
|
||||||
|
const { stateDir, repository, github, feishu, gitea } = deployment
|
||||||
|
// A Gitea-only deployment has no gh; the agent's forge identity is its Gitea account and git auth uses the token store below.
|
||||||
|
const onGitea = !!gitea && !github.cli
|
||||||
const dir = workerBin(stateDir)
|
const dir = workerBin(stateDir)
|
||||||
await mkdir(dir, { recursive: true })
|
await mkdir(dir, { recursive: true })
|
||||||
const before = await readdir(dir)
|
const before = await readdir(dir)
|
||||||
@@ -97,22 +115,30 @@ export async function writeEnvironment(deployment: Pick<Deployment, 'stateDir' |
|
|||||||
].join('\n')
|
].join('\n')
|
||||||
const run = (program: string): string => `${withoutShims}\nexec ${shellQuote(program)} "$@"`
|
const run = (program: string): string => `${withoutShims}\nexec ${shellQuote(program)} "$@"`
|
||||||
const fail = (message: string): string => `echo ${shellQuote(message)} >&2\nexit 1`
|
const fail = (message: string): string => `echo ${shellQuote(message)} >&2\nexit 1`
|
||||||
await shim('gh', run(github.cli))
|
const forge = onGitea ? `Gitea as ${gitea!.login}` : `gh, which acts as ${github.login}`
|
||||||
|
if (onGitea) await shim('gh', fail('gh is blocked here; this installation runs on Gitea.'))
|
||||||
|
else await shim('gh', run(github.cli))
|
||||||
await shim('lark-cli', feishu ? run(feishu.cli) : fail('lark-cli is unavailable: this installation has no Feishu bot.'))
|
await shim('lark-cli', feishu ? run(feishu.cli) : fail('lark-cli is unavailable: this installation has no Feishu bot.'))
|
||||||
for (const name of new Set(blockedCommands.filter(name => !WORKER_COMMANDS.includes(name)))) {
|
for (const name of new Set(blockedCommands.filter(name => !WORKER_COMMANDS.includes(name)))) {
|
||||||
await shim(name, fail(`${name} is blocked in this installation; use gh, which acts as ${github.login}.`))
|
await shim(name, fail(`${name} is blocked in this installation; use ${forge}.`))
|
||||||
}
|
}
|
||||||
for (const name of before) if (!kept.has(name)) await unlink(join(dir, name))
|
for (const name of before) if (!kept.has(name)) await unlink(join(dir, name))
|
||||||
|
// A Gitea deployment authenticates worker git over HTTPS with the agent's token, kept out of the shared credential helpers.
|
||||||
|
const credentials = onGitea ? giteaCredentialsPath(stateDir) : ''
|
||||||
|
const credentialSections = onGitea
|
||||||
|
? ['[credential]', '\thelper =', `[credential "${gitea!.url}"]`, `\thelper = ${gitQuote(giteaCredentialHelper(stateDir))}`]
|
||||||
|
: ['[credential]', '\thelper =', '[credential "https://github.com"]', `\thelper = ${gitQuote(credentialHelper(github.cli))}`,
|
||||||
|
'[url "https://github.com/"]', '\tinsteadOf = git@github.com:', '\tinsteadOf = ssh://git@github.com/']
|
||||||
await own(workerGitConfig(stateDir), [
|
await own(workerGitConfig(stateDir), [
|
||||||
MARKER,
|
MARKER,
|
||||||
// Make git refuse to commit without an explicit identity set by the worker instead of guessing from the operator's OS user.
|
// Make git refuse to commit without an explicit identity set by the worker instead of guessing from the operator's OS user.
|
||||||
'[user]', '\tuseConfigOnly = true',
|
'[user]', '\tuseConfigOnly = true',
|
||||||
'[credential]', '\thelper =',
|
...credentialSections,
|
||||||
'[credential "https://github.com"]', `\thelper = ${gitQuote(credentialHelper(github.cli))}`,
|
|
||||||
'[url "https://github.com/"]', '\tinsteadOf = git@github.com:', '\tinsteadOf = ssh://git@github.com/',
|
|
||||||
// Include Git LFS filters because the operator's global gitconfig (which usually holds them) is ignored.
|
// Include Git LFS filters because the operator's global gitconfig (which usually holds them) is ignored.
|
||||||
'[filter "lfs"]', '\tclean = git-lfs clean -- %f', '\tsmudge = git-lfs smudge -- %f', '\tprocess = git-lfs filter-process', '\trequired = true',
|
'[filter "lfs"]', '\tclean = git-lfs clean -- %f', '\tsmudge = git-lfs smudge -- %f', '\tprocess = git-lfs filter-process', '\trequired = true',
|
||||||
].join('\n') + '\n')
|
].join('\n') + '\n')
|
||||||
|
// The Gitea token store is written after the gitconfig so a failed token read leaves no orphan credential file.
|
||||||
|
if (onGitea) await writeGiteaCredentials(credentials, gitea!, env)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Applies environment overrides and prepends worker shims to `process.env.PATH`, returning a restore callback. */
|
/** Applies environment overrides and prepends worker shims to `process.env.PATH`, returning a restore callback. */
|
||||||
@@ -134,10 +160,16 @@ export async function runAsWorker(subprocess: SubprocessRuntime, argv: string[],
|
|||||||
throw new CliError(read(handle.collected.stderr).trim() || `${argv.join(' ')} ${ended}`, undefined, exitCode ?? undefined)
|
throw new CliError(read(handle.collected.stderr).trim() || `${argv.join(' ')} ${ended}`, undefined, exitCode ?? undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Verifies that worker git invocations resolve the deployment's GitHub credential helper. */
|
/** Verifies that worker git invocations resolve the deployment's credential helper (GitHub's CLI helper or the Gitea token store). */
|
||||||
export async function verifyEnvironment(deployment: Pick<Deployment, 'github' | 'stateDir'>, subprocess: SubprocessRuntime, signal: AbortSignal): Promise<void> {
|
export async function verifyEnvironment(deployment: Pick<Deployment, 'github' | 'stateDir' | 'gitea'>, subprocess: SubprocessRuntime, signal: AbortSignal): Promise<void> {
|
||||||
const { github, stateDir } = deployment
|
const { github, gitea, stateDir } = deployment
|
||||||
// `-z` separates fields with NUL and never quotes paths.
|
// `-z` separates fields with NUL and never quotes paths.
|
||||||
|
if (gitea && !github.cli) {
|
||||||
|
const helpers = (await runAsWorker(subprocess, ['git', 'config', '-z', '--show-origin', '--get-all', `credential.${gitea.url}.helper`], stateDir,
|
||||||
|
AbortSignal.any([signal, AbortSignal.timeout(60_000)])).catch(() => '')).split('\0')
|
||||||
|
if (helpers.join('\0') !== [`file:${workerGitConfig(stateDir)}`, giteaCredentialHelper(stateDir), ''].join('\0')) throw new SetupError('giteaCredentialFailed')
|
||||||
|
return
|
||||||
|
}
|
||||||
const helpers = (await runAsWorker(subprocess, ['git', 'config', '-z', '--show-origin', '--get-all', 'credential.https://github.com.helper'], stateDir, AbortSignal.any([signal, AbortSignal.timeout(60_000)])).catch(() => '')).split('\0')
|
const helpers = (await runAsWorker(subprocess, ['git', 'config', '-z', '--show-origin', '--get-all', 'credential.https://github.com.helper'], stateDir, AbortSignal.any([signal, AbortSignal.timeout(60_000)])).catch(() => '')).split('\0')
|
||||||
if (helpers.join('\0') !== [`file:${workerGitConfig(stateDir)}`, credentialHelper(github.cli), ''].join('\0')) throw new SetupError('gitTooOld')
|
if (helpers.join('\0') !== [`file:${workerGitConfig(stateDir)}`, credentialHelper(github.cli), ''].join('\0')) throw new SetupError('gitTooOld')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ export const KV = {
|
|||||||
stream: family<{ since: string; started: Record<string, string>; etag?: string }, [stream: string]>('github-stream:'),
|
stream: family<{ since: string; started: Record<string, string>; etag?: string }, [stream: string]>('github-stream:'),
|
||||||
branchCi: record<{ branch?: string; failures?: string[]; revision: number }>('github-branch-ci'),
|
branchCi: record<{ branch?: string; failures?: string[]; revision: number }>('github-branch-ci'),
|
||||||
ci: family<{ fingerprint: string; revision: number }, [number: number]>('github-ci:') },
|
ci: family<{ fingerprint: string; revision: number }, [number: number]>('github-ci:') },
|
||||||
|
/**
|
||||||
|
* Gitea Source: notification cursor and pending retries (Gitea-only deployment; see GITEA-DESIGN.md);
|
||||||
|
* per-Issue/PR read cursor shared by notification and polled reads; per-stream cursor and start times by kind.
|
||||||
|
*/
|
||||||
|
gitea: { since: record<string>('gitea-since'), retries: record<Record<string, Retry>>('gitea-retry'),
|
||||||
|
cursor: family<string, [number: number]>('gitea-cursor:'),
|
||||||
|
stream: family<{ since: string; started: Record<string, string> }, [stream: string]>('gitea-stream:') },
|
||||||
/**
|
/**
|
||||||
* Feishu Source: known chats (`true` for direct messages); per-chat history cursor and active topics mapped from topic ID
|
* Feishu Source: known chats (`true` for direct messages); per-chat history cursor and active topics mapped from topic ID
|
||||||
* to last-seen timestamp; and per-polled-thread seen message IDs and initialization flag.
|
* to last-seen timestamp; and per-polled-thread seen message IDs and initialization flag.
|
||||||
|
|||||||
+9
-1
@@ -1,10 +1,11 @@
|
|||||||
/** Structured startup and configuration errors with the settings location where the operator can fix them. */
|
/** Structured startup and configuration errors with the settings location where the operator can fix them. */
|
||||||
import { fill } from './infra/text.ts'
|
import { fill } from './infra/text.ts'
|
||||||
|
|
||||||
export type SetupPlace = 'deployment' | 'github' | 'feishu'
|
export type SetupPlace = 'deployment' | 'github' | 'gitea' | 'feishu'
|
||||||
export const SETUP_PROBLEMS = {
|
export const SETUP_PROBLEMS = {
|
||||||
foreignFile: ['deployment', '{file} was not written by dsh-superagent; move it, or choose another state directory.'],
|
foreignFile: ['deployment', '{file} was not written by dsh-superagent; move it, or choose another state directory.'],
|
||||||
gitTooOld: ['deployment', 'git does not read the github.com credential configuration dsh-superagent writes; dsh-superagent needs git 2.32 or later.'],
|
gitTooOld: ['deployment', 'git does not read the github.com credential configuration dsh-superagent writes; dsh-superagent needs git 2.32 or later.'],
|
||||||
|
giteaCredentialFailed: ['deployment', 'git does not read the Gitea credential configuration dsh-superagent writes.'],
|
||||||
environmentFailed: ['deployment', 'Preparing the agent environment failed.'],
|
environmentFailed: ['deployment', 'Preparing the agent environment failed.'],
|
||||||
repositoryUnset: ['github', 'The GitHub Source names no repository.'],
|
repositoryUnset: ['github', 'The GitHub Source names no repository.'],
|
||||||
agentCliUnset: ['github', 'The agent GitHub CLI is not set.'],
|
agentCliUnset: ['github', 'The agent GitHub CLI is not set.'],
|
||||||
@@ -14,6 +15,13 @@ export const SETUP_PROBLEMS = {
|
|||||||
repositoryUnreadable: ['github', '@{account} cannot read {repository}.'],
|
repositoryUnreadable: ['github', '@{account} cannot read {repository}.'],
|
||||||
repositoryUnreported: ['github', 'The agent GitHub CLI did not return {repository}.'],
|
repositoryUnreported: ['github', 'The agent GitHub CLI did not return {repository}.'],
|
||||||
repositoryReadOnly: ['github', '@{account} cannot push to {repository}.'],
|
repositoryReadOnly: ['github', '@{account} cannot push to {repository}.'],
|
||||||
|
giteaUrlUnset: ['gitea', 'The Gitea Source names no URL, for example https://gitea.example.com.'],
|
||||||
|
giteaRepositoryUnset: ['gitea', 'The Gitea Source names no repository.'],
|
||||||
|
giteaTokenMissing: ['gitea', 'The environment variable {name} holding the agent Gitea token is not set.'],
|
||||||
|
giteaAccountUnreadable: ['gitea', 'The Gitea account behind {name} could not be read.'],
|
||||||
|
giteaRepositoryUnreadable: ['gitea', '@{account} cannot read {repository}.'],
|
||||||
|
giteaRepositoryUnreported: ['gitea', 'Gitea did not return {repository}.'],
|
||||||
|
giteaRepositoryReadOnly: ['gitea', '@{account} cannot push to {repository}.'],
|
||||||
feishuCliFailed: ['feishu', 'The bot lark-cli does not work.'],
|
feishuCliFailed: ['feishu', 'The bot lark-cli does not work.'],
|
||||||
feishuNoApp: ['feishu', 'auth status of the bot lark-cli did not report the app.'],
|
feishuNoApp: ['feishu', 'auth status of the bot lark-cli did not report the app.'],
|
||||||
feishuBotNotReady: ['feishu', 'The bot identity of app {app} is {status}; configure the app secret with lark-cli.'],
|
feishuBotNotReady: ['feishu', 'The bot identity of app {app} is {status}; configure the app secret with lark-cli.'],
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/** Minimal Gitea REST client: token auth, zod-validated responses, pagination; fetch is injectable for tests. */
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
/** An HTTP failure from the Gitea API; `status` drives gone/retry decisions, `body` aids diagnosis. */
|
||||||
|
export class GiteaApiError extends Error {
|
||||||
|
constructor(readonly status: number, message: string, readonly body = '') { super(message) }
|
||||||
|
}
|
||||||
|
/** The parts of a Response this client uses; the global `fetch` satisfies it, tests return plain objects. */
|
||||||
|
export interface GiteaResponse { status: number; ok: boolean; headers: { get(name: string): string | null }; text(): Promise<string> }
|
||||||
|
export type GiteaFetch = (url: string, init: { method: string; headers: Record<string, string>; body?: string; signal?: AbortSignal }) => Promise<GiteaResponse>
|
||||||
|
|
||||||
|
export interface GiteaTarget { url: string; token: string }
|
||||||
|
|
||||||
|
/** Reads the token from `env[tokenEnv]`; the token is never stored in Settings, only its variable name is. */
|
||||||
|
export function giteaClient(url: string, tokenEnv: string, env: Record<string, string | undefined> = process.env,
|
||||||
|
fetchImpl: GiteaFetch = fetch): GiteaApi {
|
||||||
|
if (!url) throw new Error('The Gitea Source names no URL')
|
||||||
|
const token = env[tokenEnv] ?? ''
|
||||||
|
if (!token) throw new Error(`The Gitea Source reads its token from ${tokenEnv}, which is not set`)
|
||||||
|
return new GiteaApi({ url: url.replace(/\/+$/, ''), token }, fetchImpl)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One configured Gitea account; every read and reply of the Source goes through it. */
|
||||||
|
export class GiteaApi {
|
||||||
|
constructor(readonly target: GiteaTarget, private readonly fetchImpl: GiteaFetch = fetch) {}
|
||||||
|
/** Fetches a path under `/api/v1/` and returns raw status, headers and text for header-dependent reads. */
|
||||||
|
async raw(path: string, init: { method?: string; body?: unknown; signal?: AbortSignal } = {}): Promise<GiteaResponse> {
|
||||||
|
return await this.fetchImpl(`${this.target.url}/api/v1/${path.replace(/^\//, '')}`, {
|
||||||
|
method: init.method ?? 'GET',
|
||||||
|
headers: { authorization: `token ${this.target.token}`,
|
||||||
|
...(init.body === undefined ? {} : { 'content-type': 'application/json' }) },
|
||||||
|
...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
|
||||||
|
...(init.signal === undefined ? {} : { signal: init.signal }) })
|
||||||
|
}
|
||||||
|
/** Fetches a path and validates the JSON answer; a non-2xx or an unparseable body is an error. */
|
||||||
|
async json<T>(schema: z.ZodType<T>, path: string, init: { method?: string; body?: unknown; signal?: AbortSignal } = {}): Promise<T> {
|
||||||
|
const response = await this.raw(path, init)
|
||||||
|
const text = await response.text()
|
||||||
|
if (!response.ok) throw new GiteaApiError(response.status, `Gitea answered ${response.status} for ${path}`, text.slice(0, 300))
|
||||||
|
let value: unknown
|
||||||
|
try { value = JSON.parse(text) } catch { throw new GiteaApiError(response.status, `Gitea returned no JSON for ${path}`, text.slice(0, 300)) }
|
||||||
|
return schema.parse(value)
|
||||||
|
}
|
||||||
|
/** Pages a list endpoint (`?page=&limit=`) until a short page; `maxPages` bounds a runaway loop. */
|
||||||
|
async pages<T>(schema: z.ZodType<T[]>, path: string, perPage = 100, maxPages = 100): Promise<T[]> {
|
||||||
|
const joiner = path.includes('?') ? '&' : '?'
|
||||||
|
const all: T[] = []
|
||||||
|
for (let page = 1; page <= maxPages; page++) {
|
||||||
|
const items = await this.json(schema, `${path}${joiner}page=${page}&limit=${perPage}`)
|
||||||
|
all.push(...items)
|
||||||
|
if (items.length < perPage) break
|
||||||
|
}
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
/** Pages a list endpoint and returns raw items for item-level validation; aborts with the poll's signal. */
|
||||||
|
async list(path: string, signal?: AbortSignal, perPage = 100, maxPages = 100): Promise<unknown[]> {
|
||||||
|
const joiner = path.includes('?') ? '&' : '?'
|
||||||
|
const all: unknown[] = []
|
||||||
|
for (let page = 1; page <= maxPages; page++) {
|
||||||
|
if (signal?.aborted) throw new Error('The Gitea read was aborted')
|
||||||
|
const items = await this.json(z.array(z.unknown()), `${path}${joiner}page=${page}&limit=${perPage}`, { signal })
|
||||||
|
all.push(...items)
|
||||||
|
if (items.length < perPage) break
|
||||||
|
}
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/** Gitea outbound channel: posts Issue/PR comments under the agent's Gitea account, idempotent via an embedded marker. */
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { OutboundMessage } from '../core/index.ts'
|
||||||
|
import { sameName } from '../infra/text.ts'
|
||||||
|
import { giteaClient, GiteaApiError } from './gitea-api.ts'
|
||||||
|
import { GiteaCommentSchema } from './gitea-trigger.ts'
|
||||||
|
import type { SourceContext } from './host.ts'
|
||||||
|
import type { ChannelLink } from './kind.ts'
|
||||||
|
|
||||||
|
/** A locked Issue (403) never takes replies; Gitea has no 410, and a 404 is decided by the Issue read in `sendGitea`. */
|
||||||
|
const goneError = (error: unknown): boolean => error instanceof GiteaApiError
|
||||||
|
&& (error.status === 410 || (error.status === 403 && /locked/i.test(`${error.message}${error.body}`)))
|
||||||
|
const IssueStub = z.looseObject({ number: z.number() })
|
||||||
|
/** Posts a comment; returns 'sent' when the marker is already stored or the POST landed, 'gone' when the Issue is deleted or locked. */
|
||||||
|
export async function sendGitea(host: Pick<SourceContext, 'commands'>, message: OutboundMessage): Promise<'sent' | 'gone'> {
|
||||||
|
const deployment = host.commands.deployment
|
||||||
|
const gitea = deployment.gitea
|
||||||
|
if (!gitea) throw new Error('The Gitea channel needs a Gitea deployment')
|
||||||
|
const number = Number(message.channel.address.number)
|
||||||
|
if (!number) throw new Error('Missing Gitea reply target')
|
||||||
|
const api = giteaClient(gitea.url, gitea.tokenEnv)
|
||||||
|
const path = `repos/${deployment.repository}/issues/${number}/comments`
|
||||||
|
const marker = `<!-- dsh-superagent:${message.id} -->`
|
||||||
|
try {
|
||||||
|
// Gitea answers 404 both for a deleted Issue and for a lost access grant; read the Issue first to tell them apart.
|
||||||
|
const issue = await api.json(IssueStub, `repos/${deployment.repository}/issues/${number}`)
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (error instanceof GiteaApiError && error.status === 404) return undefined
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
if (!issue) return 'gone'
|
||||||
|
const mine = (comment: z.infer<typeof GiteaCommentSchema>) =>
|
||||||
|
sameName(comment.user?.login ?? '', gitea.login) && (comment.body ?? '').includes(marker)
|
||||||
|
if ((await api.pages(z.array(GiteaCommentSchema), path)).some(mine)) return 'sent'
|
||||||
|
await api.json(z.unknown(), path, { method: 'POST', body: { body: `${message.text}\n\n${marker}` } })
|
||||||
|
return 'sent'
|
||||||
|
} catch (error) {
|
||||||
|
if (goneError(error)) return 'gone'
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** The panel link of a reply address; every Gitea address carries its own web URL. */
|
||||||
|
export function giteaLink(address: Record<string, string>): ChannelLink {
|
||||||
|
return { url: address.url ?? '', label: { zh: `Gitea #${address.number ?? ''}`, en: `Gitea #${address.number ?? ''}` } }
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/** Normalizes Gitea issues, pull requests and reviews into domain Triggers (see GITEA-DESIGN.md). */
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { key, type ChannelRef, type Trigger } from '../core/index.ts'
|
||||||
|
import { sameName } from '../infra/text.ts'
|
||||||
|
import { mentions } from './github-trigger.ts'
|
||||||
|
|
||||||
|
export const SOURCE = 'gitea'
|
||||||
|
/** Reply address: an Issue or PR page; the web URL is always stored so later reads need no configuration. */
|
||||||
|
export const giteaChannel = (number: number, url?: string): ChannelRef =>
|
||||||
|
({ source: SOURCE, thread: `gitea:${number}`, address: { number: String(number), ...(url ? { url } : {}) } })
|
||||||
|
export const giteaPage = (url: string, repository: string, number: number): string => `${url}/${repository}/issues/${number}`
|
||||||
|
/** Parses a web or API URL for an Issue or PR of `repository`; Gitea numbers a PR's Issue page too, both route to `number`. */
|
||||||
|
export function giteaAddress(url: string, repository: string): { number: number; pr: boolean } | undefined {
|
||||||
|
const match = /^https?:\/\/[^/]+\/(?:api\/v1\/)?repos\/([^/]+\/[^/]+)\/(issues|pulls)\/(\d+)\/?/i.exec(url)
|
||||||
|
if (!match || !sameName(match[1]!, repository)) return undefined
|
||||||
|
return { number: Number(match[3]), pr: match[2]!.toLowerCase() === 'pulls' }
|
||||||
|
}
|
||||||
|
/** Gitea reports a deleted or mirrored-away author as `null`; display it like the web does. */
|
||||||
|
export const GiteaUserSchema = z.looseObject({ login: z.string(), id: z.number().optional() }).nullable()
|
||||||
|
.transform(user => user ?? { login: 'ghost', id: undefined })
|
||||||
|
/** An Issue as `issues/{index}` returns it; Gitea sets `pull_request` when the number is a pull request, like GitHub. */
|
||||||
|
export const GiteaIssueSchema = z.looseObject({
|
||||||
|
number: z.number(), title: z.string(), body: z.string().nullish(), user: GiteaUserSchema,
|
||||||
|
state: z.string(), comments: z.number().default(0), created_at: z.string(), updated_at: z.string().nullish(),
|
||||||
|
html_url: z.string(), pull_request: z.looseObject({ url: z.string().nullish() }).nullish(),
|
||||||
|
})
|
||||||
|
export type GiteaIssue = z.infer<typeof GiteaIssueSchema>
|
||||||
|
/** A conversation comment on an Issue or PR; `issue_url` names its owner in the repository-wide comments stream. */
|
||||||
|
export const GiteaCommentSchema = z.looseObject({
|
||||||
|
id: z.number(), body: z.string().nullish(), user: GiteaUserSchema, html_url: z.string().nullish(),
|
||||||
|
created_at: z.string().nullish(), updated_at: z.string().nullish(),
|
||||||
|
issue_url: z.string().nullish(), pull_request_url: z.string().nullish(),
|
||||||
|
})
|
||||||
|
export type GiteaComment = z.infer<typeof GiteaCommentSchema>
|
||||||
|
/** A pull request review; a decided review still reaches the ticket even with an empty body, like GitHub's. */
|
||||||
|
export const GiteaReviewSchema = z.looseObject({
|
||||||
|
id: z.number(), state: z.string().nullish(), body: z.string().nullish(), commit_id: z.string().nullish(),
|
||||||
|
user: GiteaUserSchema, html_url: z.string().nullish(), submitted_at: z.string().nullish(), updated_at: z.string().nullish(),
|
||||||
|
})
|
||||||
|
export type GiteaReview = z.infer<typeof GiteaReviewSchema>
|
||||||
|
export interface GiteaRef { number: number; isPr: boolean; url?: string; prAuthor?: string }
|
||||||
|
/** Gitea's API has no bot flag (checked on 1.25.4); the Policy's `bots` list is the lever, so every other sender reads as human. */
|
||||||
|
export const isHumanUser = (user: { login: string }, agent: string): boolean => !sameName(user.login, agent)
|
||||||
|
function author(user: { login: string }, agent: string): Pick<Trigger, 'actor' | 'human'> {
|
||||||
|
return { actor: { source: SOURCE, id: user.login }, human: isHumanUser(user, agent) }
|
||||||
|
}
|
||||||
|
/** Builds the Trigger for an opened Issue or PR; the `kind` separates the admission rows. `/pulls` items carry no `pull_request` meta, so the stream passes `isPr` explicitly. */
|
||||||
|
export function issueTrigger(issue: Pick<GiteaIssue, 'number' | 'title' | 'body' | 'user' | 'created_at' | 'html_url' | 'pull_request'>,
|
||||||
|
agent: string, isPr = !!issue.pull_request): Trigger {
|
||||||
|
const body = issue.body ?? ''
|
||||||
|
return { id: key('gitea-issue', String(issue.number)), source: SOURCE,
|
||||||
|
threads: [{ source: SOURCE, id: String(issue.number) }], ...author(issue.user, agent), at: issue.created_at,
|
||||||
|
text: `${issue.title}\n\n${body}`,
|
||||||
|
...(mentions(body, agent) ? { addressed: 'mention' as const } : {}),
|
||||||
|
channel: giteaChannel(issue.number, issue.html_url), url: issue.html_url,
|
||||||
|
meta: { number: issue.number, isPr, ...(isPr ? { prAuthor: issue.user.login } : {}), kind: isPr ? 'pull' : 'issue' } }
|
||||||
|
}
|
||||||
|
/** Builds the Trigger for a conversation comment; edits count as new events through `updated_at` in the id. */
|
||||||
|
export function commentTrigger(comment: GiteaComment, ref: GiteaRef, agent: string): Trigger {
|
||||||
|
const at = comment.updated_at ?? comment.created_at ?? new Date().toISOString()
|
||||||
|
const version = comment.updated_at ?? ''
|
||||||
|
return { id: key('gitea-comment', comment.html_url ?? `gitea:${ref.number}`, String(comment.id), version), source: SOURCE,
|
||||||
|
threads: [{ source: SOURCE, id: String(ref.number) }], ...author(comment.user, agent), at,
|
||||||
|
text: comment.body ?? '',
|
||||||
|
...(mentions(comment.body ?? '', agent) ? { addressed: 'mention' as const } : {}),
|
||||||
|
channel: giteaChannel(ref.number, comment.html_url ?? ref.url),
|
||||||
|
...(comment.html_url ?? ref.url ? { url: comment.html_url ?? ref.url } : {}),
|
||||||
|
meta: { number: ref.number, isPr: ref.isPr, ...(ref.prAuthor ? { prAuthor: ref.prAuthor } : {}),
|
||||||
|
commentId: comment.id, kind: 'conversation' } }
|
||||||
|
}
|
||||||
|
/** Builds the Trigger for a PR review; the decision text rides even when the body is empty. */
|
||||||
|
export function reviewTrigger(review: GiteaReview, ref: GiteaRef, agent: string): Trigger {
|
||||||
|
const at = review.updated_at ?? review.submitted_at ?? new Date().toISOString()
|
||||||
|
const version = review.updated_at ?? review.submitted_at ?? ''
|
||||||
|
const decided = review.state && review.state !== 'COMMENTED'
|
||||||
|
const decision = decided ? `${review.state} review of commit ${review.commit_id ?? 'unknown'}` : ''
|
||||||
|
const text = [decision, review.body ?? ''].filter(Boolean).join('\n\n')
|
||||||
|
return { id: key('gitea-review', review.html_url ?? `gitea:${ref.number}`, String(review.id), version), source: SOURCE,
|
||||||
|
threads: [{ source: SOURCE, id: String(ref.number) }], ...author(review.user, agent), at, text,
|
||||||
|
...(mentions(review.body ?? '', agent) ? { addressed: 'mention' as const } : {}),
|
||||||
|
channel: giteaChannel(ref.number, review.html_url ?? ref.url),
|
||||||
|
...(review.html_url ?? ref.url ? { url: review.html_url ?? ref.url } : {}),
|
||||||
|
meta: { number: ref.number, isPr: ref.isPr, ...(ref.prAuthor ? { prAuthor: ref.prAuthor } : {}),
|
||||||
|
reviewId: review.id, kind: 'review' } }
|
||||||
|
}
|
||||||
|
/** The number a repository-wide comment belongs to, from its `issue_url`. */
|
||||||
|
export function commentNumber(issueUrl: string | null | undefined, repository: string): number {
|
||||||
|
const match = /^https?:\/\/[^/]+\/api\/v1\/repos\/([^/]+\/[^/]+)\/issues\/(\d+)/i.exec(issueUrl ?? '')
|
||||||
|
return match && sameName(match[1]!, repository) ? Number(match[2]) : 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
/** Gitea source kind: notification, stream and thread polling, thread context, and the Cordis plugin entry (see GITEA-DESIGN.md). */
|
||||||
|
import type { Context } from '@deepseek-ai/cordis'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { sameName } from '../infra/text.ts'
|
||||||
|
import { GiteaSetupSchema } from '../deployment.ts'
|
||||||
|
import { validItems, type Tally } from '../infra/commands.ts'
|
||||||
|
import { KV } from '../kv.ts'
|
||||||
|
import { giteaClient, GiteaApiError, type GiteaApi } from './gitea-api.ts'
|
||||||
|
import { GiteaCommentSchema, GiteaIssueSchema, GiteaReviewSchema, SOURCE, commentNumber, commentTrigger, giteaAddress, issueTrigger, reviewTrigger,
|
||||||
|
type GiteaComment, type GiteaIssue, type GiteaRef } from './gitea-trigger.ts'
|
||||||
|
import { giteaLink, sendGitea } from './gitea-channel.ts'
|
||||||
|
import { health, sourceContext, type Health, type SourceContext } from './host.ts'
|
||||||
|
import { cursorAt, pause, POLL_HORIZON_MS, pollThreads as pollEach } from './poll.ts'
|
||||||
|
import { pollFailure } from './github.ts'
|
||||||
|
import type { ContextMessage, SourceKind, ThreadContext } from './kind.ts'
|
||||||
|
import type { Thread } from '../core/index.ts'
|
||||||
|
import type { Retry } from './github.ts'
|
||||||
|
|
||||||
|
export const GiteaConfigSchema = GiteaSetupSchema.extend({
|
||||||
|
pollSeconds: z.number().int().min(60).max(3600).default(60),
|
||||||
|
failuresBeforeError: z.number().int().min(1).max(20).default(3),
|
||||||
|
observeIssues: z.boolean().default(false), observePulls: z.boolean().default(false), observeComments: z.boolean().default(false),
|
||||||
|
}).strict()
|
||||||
|
export type GiteaConfig = z.infer<typeof GiteaConfigSchema>
|
||||||
|
const OBSERVED = { issues: 'observeIssues', pulls: 'observePulls', comments: 'observeComments' } as const
|
||||||
|
type Scope = keyof typeof OBSERVED
|
||||||
|
function scopeOf(meta: Record<string, unknown>): Scope | undefined {
|
||||||
|
if (meta.kind === 'issue') return 'issues'
|
||||||
|
if (meta.kind === 'pull') return 'pulls'
|
||||||
|
return meta.kind === 'conversation' || meta.kind === 'review' ? 'comments' : undefined
|
||||||
|
}
|
||||||
|
/** The notification of a Gitea thread; `subject.url` is the API address of its Issue or PR. */
|
||||||
|
const NotificationSchema = z.looseObject({
|
||||||
|
repository: z.looseObject({ full_name: z.string() }), subject: z.looseObject({ url: z.string().nullish() }),
|
||||||
|
})
|
||||||
|
/** Reads the Issue body and the conversation of an Issue or PR for assessments; Gitea comment threads are flat. */
|
||||||
|
export const giteaThreadContext: ThreadContext<GiteaConfig> = {
|
||||||
|
messageId: trigger => trigger.meta.kind === 'issue' || trigger.meta.kind === 'pull' ? `${trigger.meta.number}-body`
|
||||||
|
: trigger.meta.kind === 'review' ? `r${trigger.meta.reviewId}` : `c${trigger.meta.commentId}`,
|
||||||
|
read: async (_commands, trigger, part, cursor, config) => {
|
||||||
|
const number = Number(trigger.meta.number)
|
||||||
|
if (!number) throw new Error('Gitea thread has no Issue or PR number')
|
||||||
|
if (!config.url) throw new Error('The Gitea Source names no URL')
|
||||||
|
const api = giteaClient(config.url, config.tokenEnv)
|
||||||
|
const issue = await api.json(GiteaIssueSchema, `repos/${config.repository}/issues/${number}`)
|
||||||
|
const bodyMessage: ContextMessage = { id: `${number}-body`, from: issue.user.login, at: issue.created_at,
|
||||||
|
text: `${issue.title}\n\n${issue.body ?? ''}`, ...(issue.html_url ? { url: issue.html_url } : {}) }
|
||||||
|
if (part === 'root' || part === 'parent') return { messages: [bodyMessage], body: { messages: [bodyMessage] } }
|
||||||
|
const comments = await api.pages(z.array(GiteaCommentSchema), `repos/${config.repository}/issues/${number}/comments`)
|
||||||
|
const messages: ContextMessage[] = [bodyMessage, ...comments.map(comment => ({
|
||||||
|
id: `c${comment.id}`, from: comment.user.login, at: comment.updated_at ?? comment.created_at ?? issue.updated_at ?? '',
|
||||||
|
text: comment.body ?? '', ...(comment.html_url ? { url: comment.html_url } : {}),
|
||||||
|
})).sort((a, b) => Date.parse(a.at) - Date.parse(b.at))]
|
||||||
|
if (issue.pull_request) {
|
||||||
|
const reviews = await api.pages(z.array(GiteaReviewSchema), `repos/${config.repository}/pulls/${number}/reviews`)
|
||||||
|
for (const review of reviews) if (review.body) messages.push({ id: `r${review.id}`, from: review.user.login,
|
||||||
|
at: review.submitted_at ?? '', text: review.body, ...(review.html_url ? { url: review.html_url } : {}) })
|
||||||
|
messages.sort((a, b) => Date.parse(a.at) - Date.parse(b.at))
|
||||||
|
}
|
||||||
|
const page = Math.max(1, Number(/^p(\d+)$/.exec(cursor ?? '')?.[1] ?? 1) || 1)
|
||||||
|
const items = messages.slice((page - 1) * 100, page * 100)
|
||||||
|
return { messages: items, ...(messages.length > page * 100 ? { next: `p${page + 1}` } : {}), body: { messages: items } }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
export const giteaKind: SourceKind<GiteaConfig> = {
|
||||||
|
kind: SOURCE,
|
||||||
|
title: { zh: 'Gitea', en: 'Gitea' },
|
||||||
|
description: { zh: '智能体的 Gitea 账号;接收这个 Gitea 实例发给它的通知和已订阅 Issue 与 PR 的讨论。',
|
||||||
|
en: 'The agent\'s Gitea account; receives the notifications this Gitea instance sends it and the discussion on subscribed Issues and PRs.' },
|
||||||
|
schema: GiteaConfigSchema,
|
||||||
|
fields: [
|
||||||
|
{ key: 'url', type: 'text', pattern: 'https?://[^\\/]+', label: { zh: 'Gitea 实例地址', en: 'Gitea instance' },
|
||||||
|
hint: { zh: '不带末尾斜杠,例如 https://gitea.example.com。保存时检查智能体账号能否推送到仓库,改动后重启 dsh-superagent。',
|
||||||
|
en: 'Without a trailing slash, for example https://gitea.example.com. Saving checks that the agent account can push to the repository; changing it restarts dsh-superagent.' } },
|
||||||
|
{ key: 'repository', type: 'text', pattern: '[A-Za-z0-9_.\-]+/[A-Za-z0-9_.\-]+', label: { zh: '监听的仓库', en: 'Repository' },
|
||||||
|
hint: { zh: '写作 owner/name。这个来源接收这个仓库的通知和讨论。', en: 'Written owner/name. This Source receives the repository\'s notifications and discussion.' } },
|
||||||
|
{ key: 'tokenEnv', type: 'text', pattern: '[A-Za-z_][A-Za-z0-9_]*', label: { zh: '智能体令牌的变量名', en: 'Token variable' },
|
||||||
|
hint: { zh: '存放智能体 Gitea API 令牌的环境变量名称,令牌本身不写进配置;建议放进取凭据文件(DSH 会注入进程环境)。',
|
||||||
|
en: 'Name of the environment variable holding the agent\'s Gitea API token; the token itself is never stored in configuration. Prefer the credentials file, which DSH injects into the process environment.' } },
|
||||||
|
{ key: 'pollSeconds', type: 'number', advanced: true, min: 60, max: 3600, label: { zh: '轮询间隔 / 秒', en: 'Polling interval / seconds' },
|
||||||
|
hint: { zh: '实例要求更长间隔时以实例为准。', en: 'A longer interval requested by the instance wins.' } },
|
||||||
|
{ key: 'failuresBeforeError', type: 'number', advanced: true, min: 1, max: 20, label: { zh: '连续失败几次后报告异常', en: 'Failed polls before reporting an error' },
|
||||||
|
hint: { zh: '单次网络中断会在下一次轮询时自动重试,不必报告。', en: 'A single network interruption is retried on the next poll and need not be reported.' } },
|
||||||
|
{ key: 'observeIssues', type: 'switch', label: { zh: '接收仓库里的新 Issue', en: 'Receive new Issues in the repository' },
|
||||||
|
hint: { zh: '打开后,没有 @ 智能体的新 Issue 也会收到,由策略的接收表决定是否成为候选。从打开的时候开始,不读以前的。',
|
||||||
|
en: 'New Issues that do not mention the agent are received too, and the Policy\'s admission table decides whether they become candidates. Starts when switched on; earlier ones are not read.' } },
|
||||||
|
{ key: 'observePulls', type: 'switch', label: { zh: '接收仓库里的新 PR', en: 'Receive new pull requests in the repository' },
|
||||||
|
hint: { zh: '打开后,没有 @ 智能体的新 PR 也会收到,由策略的接收表决定是否成为候选。',
|
||||||
|
en: 'New pull requests that do not mention the agent are received too, and the Policy\'s admission table decides whether they become candidates.' } },
|
||||||
|
{ key: 'observeComments', type: 'switch', label: { zh: '接收仓库里的评论', en: 'Receive comments in the repository' },
|
||||||
|
hint: { zh: '打开后,Issue 和 PR 上没有 @ 智能体的评论也会收到,由策略的接收表决定是否成为候选。',
|
||||||
|
en: 'Comments on Issues and PRs that do not mention the agent are received too, and the Policy\'s admission table decides whether they become candidates.' } }],
|
||||||
|
// What the Source observes across the repository it receives in full; elsewhere, only what addresses the agent.
|
||||||
|
scope: ({ meta }, config) => {
|
||||||
|
const row = scopeOf(meta)
|
||||||
|
return { listened: true, observed: row !== undefined && config[OBSERVED[row]], ...row ? { row } : {} }
|
||||||
|
},
|
||||||
|
health: ['gitea'],
|
||||||
|
channel: { send: sendGitea, link: giteaLink },
|
||||||
|
threadContext: giteaThreadContext,
|
||||||
|
// `#12` or `12`, or the page of an Issue or PR of any repository, names Thread 12.
|
||||||
|
find: query => {
|
||||||
|
const number = (/^#?(\d+)$/.exec(query.trim()) ?? /\/(?:issues|pulls)\/(\d+)/.exec(query))?.[1]
|
||||||
|
return { threads: number ? [`${SOURCE}:${number}`] : [], terms: [] }
|
||||||
|
},
|
||||||
|
policy: {
|
||||||
|
schema: z.object({ allowed: z.array(z.object({ id: z.string().regex(/^[A-Za-z0-9._-]+$/) }).strict()).default([]),
|
||||||
|
bots: z.array(z.object({ id: z.string().regex(/^[A-Za-z0-9._-]+$/) }).strict()).optional() }).strict(),
|
||||||
|
fields: [{ key: 'allowed', type: 'rows', label: { zh: '能发起工作的 Gitea 账号', en: 'Gitea accounts that may originate work' },
|
||||||
|
columns: [{ key: 'id', type: 'text', label: { zh: '账号', en: 'Account' }, pattern: '[A-Za-z0-9._\-]+' }] },
|
||||||
|
{ key: 'bots', type: 'rows', label: { zh: '评论可以成为候选的机器人', en: 'Bots whose comments may become candidates' },
|
||||||
|
hint: { zh: 'Gitea 的 API 不标记机器人账号,请把自动化账号列在这里:名单里的账号的消息在来源收到时成为推断的候选,交给评估。',
|
||||||
|
en: 'The Gitea API has no bot flag; list automation accounts here. A message of a bot on this list becomes an inferred candidate for an assessment when the Source receives it.' },
|
||||||
|
columns: [{ key: 'id', type: 'text', label: { zh: '账号', en: 'Account' }, pattern: '[A-Za-z0-9._\-]+' }] }],
|
||||||
|
explicit: { zh: '名单里的账号在 Issue 或评论里 @ 智能体的账号。', en: 'An account on the list mentions the agent\'s account in an Issue or a comment.' },
|
||||||
|
rows(stored) {
|
||||||
|
const config = GiteaConfigSchema.partial().parse(stored)
|
||||||
|
const note = (observed: boolean | undefined) => observed ? { zh: '全部', en: 'All' } : { zh: '仅 @', en: '@ only' }
|
||||||
|
const row = (id: Scope, label: { zh: string; en: string }) => ({ id, label, note: note(config[OBSERVED[id]]),
|
||||||
|
cells: { listedUnaddressed: !!config[OBSERVED[id]], unlistedAddressed: true, unlistedUnaddressed: !!config[OBSERVED[id]] } })
|
||||||
|
return [row('issues', { zh: '新 Issue', en: 'New Issues' }), row('pulls', { zh: '新 PR', en: 'New pull requests' }),
|
||||||
|
row('comments', { zh: '评论', en: 'Comments' })]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
environment: {
|
||||||
|
variables: [
|
||||||
|
{ key: 'DSH_SUPERAGENT_GITEA_REPOSITORY', description: { zh: '这个来源监听的仓库,owner/name', en: 'The repository this Source watches, as owner/name' } },
|
||||||
|
{ key: 'DSH_SUPERAGENT_GITEA_URL', description: { zh: 'Gitea 实例地址,不带末尾斜杠', en: 'The Gitea instance URL without a trailing slash' } },
|
||||||
|
{ key: 'DSH_SUPERAGENT_GITEA_ACCOUNT', description: { zh: '智能体的 Gitea 账号;这里的 Issue 评论都以它的名义发出', en: 'The agent\'s Gitea account; Issue comments here go out under its name' } },
|
||||||
|
{ key: 'DSH_SUPERAGENT_GITEA_NUMBER', description: { zh: '接下工单的 Issue 或 PR 编号;工单不是从这个来源来的时为空', en: 'Number of the Issue or PR that started the ticket; empty when the ticket did not start on this Source' } },
|
||||||
|
],
|
||||||
|
values: ({ deployment, config, trigger }) => {
|
||||||
|
const number = trigger?.threads[0]?.source === SOURCE ? Number(trigger.meta.number) || 0 : 0
|
||||||
|
return { DSH_SUPERAGENT_GITEA_REPOSITORY: deployment.repository, DSH_SUPERAGENT_GITEA_URL: config.url || deployment.gitea?.url || '',
|
||||||
|
DSH_SUPERAGENT_GITEA_ACCOUNT: deployment.gitea?.login ?? '', DSH_SUPERAGENT_GITEA_NUMBER: number ? String(number) : '' }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
facts: (deployment): Record<string, { zh: string; en: string }> => deployment?.gitea ? {
|
||||||
|
tokenEnv: { zh: `当前账号 @${deployment.gitea.login}`, en: `Current account @${deployment.gitea.login}` },
|
||||||
|
} : {},
|
||||||
|
}
|
||||||
|
export const name = 'dsh-superagent-gitea'
|
||||||
|
export const inject = ['orchestrator']
|
||||||
|
export function apply(ctx: Context): void {
|
||||||
|
ctx.effect(() => ctx.orchestrator.sources.register(giteaKind), 'gitea: source kind')
|
||||||
|
ctx.effect(() => {
|
||||||
|
const source = sourceContext(ctx.orchestrator, giteaKind)
|
||||||
|
const controller = new AbortController()
|
||||||
|
// Unhandled rejections terminate the DSH process, so a failure escaping the polling loop is reported as source health instead.
|
||||||
|
const running = watch(source, controller.signal)
|
||||||
|
.catch((error: unknown) => { source.report('gitea', { state: 'error', detail: String(error) }) })
|
||||||
|
return async () => { controller.abort(); await running }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async function watch(ctx: SourceContext, signal: AbortSignal): Promise<void> {
|
||||||
|
let interval = 60
|
||||||
|
let failures = 0
|
||||||
|
await ctx.ready
|
||||||
|
// Setup restarts dsh-superagent once it resolves the accounts; until then nothing is polled.
|
||||||
|
if (!ctx.configured()) { ctx.report('gitea', health('paused', 'setupPending')); return }
|
||||||
|
while (!signal.aborted) {
|
||||||
|
const config = ctx.config<GiteaConfig>(SOURCE)
|
||||||
|
try {
|
||||||
|
if (config) interval = await pollRound(ctx, signal)
|
||||||
|
else ctx.report('gitea', health('paused', 'sourceOff'))
|
||||||
|
failures = 0
|
||||||
|
} catch (error) {
|
||||||
|
failures += 1
|
||||||
|
const failed = pollFailure(failures, config?.failuresBeforeError ?? 1, error)
|
||||||
|
if (!signal.aborted && failed) ctx.report('gitea', failed)
|
||||||
|
}
|
||||||
|
await pause(Math.max(interval, ctx.config<GiteaConfig>(SOURCE)?.pollSeconds ?? 60) * 1000, signal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interface Round {
|
||||||
|
started: string; signal?: AbortSignal | undefined
|
||||||
|
read: Set<number>; failures: string[]; dropped: number[]; abandoned: number[]; tally: Tally
|
||||||
|
}
|
||||||
|
const newRound = (signal?: AbortSignal): Round => ({ started: new Date().toISOString(), signal, read: new Set(),
|
||||||
|
failures: [], dropped: [], abandoned: [], tally: { skipped: 0 } })
|
||||||
|
const stopped = (ctx: SourceContext, round: Round): boolean => !!round.signal?.aborted || !ctx.config(SOURCE)
|
||||||
|
const RETRY_WINDOW_MS = 24 * 3600_000
|
||||||
|
/** Gitea answers 404 for deleted Issues, PRs and comments; lost access fails the notification read and surfaces as health instead. */
|
||||||
|
const deleted = (error: unknown): boolean => error instanceof GiteaApiError && (error.status === 404 || error.status === 410)
|
||||||
|
/** The one API client of a round; every Gitea read and reply goes through the agent's own account. */
|
||||||
|
function api(ctx: SourceContext): GiteaApi {
|
||||||
|
const gitea = ctx.deployment.gitea
|
||||||
|
if (!gitea) throw new Error('The Gitea Source needs a Gitea deployment')
|
||||||
|
return giteaClient(gitea.url, gitea.tokenEnv)
|
||||||
|
}
|
||||||
|
const agentLogin = (ctx: SourceContext): string => ctx.deployment.gitea?.login ?? ''
|
||||||
|
/** Records a failed read on the Thread and in the round; deleted (404) or abandoned (24h+) threads do not fail the round. */
|
||||||
|
function failed(ctx: SourceContext, round: Round, number: number, error: unknown, abandoned = false): void {
|
||||||
|
if (deleted(error)) round.dropped.push(number)
|
||||||
|
else if (abandoned) round.abandoned.push(number)
|
||||||
|
else round.failures.push(`#${number}: ${String(error)}`)
|
||||||
|
ctx.threads.patch(`${SOURCE}:${number}`, { pollError: String(error) })
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* One polling round: notifications, then observed repository streams, then subscribed Threads. Each step and each Issue,
|
||||||
|
* PR or Thread fails independently so all steps run before the round fails.
|
||||||
|
* @returns the polling interval requested by the instance, in seconds.
|
||||||
|
*/
|
||||||
|
export async function pollRound(ctx: SourceContext, signal?: AbortSignal): Promise<number> {
|
||||||
|
const round = newRound(signal)
|
||||||
|
const notified = await pollNotifications(ctx, round).then(value => ({ value }), (error: unknown) => ({ error }))
|
||||||
|
await pollRepository(ctx, round)
|
||||||
|
await pollThreads(ctx, signal, round)
|
||||||
|
if ('error' in notified) throw notified.error
|
||||||
|
const { count, interval } = notified.value
|
||||||
|
if (signal?.aborted) return interval
|
||||||
|
if (round.failures.length) throw new Error(`${round.failures.length} Gitea reads failed: ${round.failures.slice(0, 3).join('; ')}`)
|
||||||
|
const named = (numbers: number[]) => [...new Set(numbers)].sort((a, b) => a - b).map(number => `#${number}`).join(', ')
|
||||||
|
const dropped = (round.dropped.length ? `; deleted on Gitea: ${named(round.dropped)}` : '')
|
||||||
|
+ (round.abandoned.length ? `; dropped after their reads kept failing: ${named(round.abandoned)}` : '')
|
||||||
|
+ (round.tally.skipped ? `; ${round.tally.skipped} unreadable items skipped` : '')
|
||||||
|
ctx.report('gitea', { state: 'ready', detail: `${count} notifications; next poll ≥ ${interval}s${dropped}`, at: round.started })
|
||||||
|
return interval
|
||||||
|
}
|
||||||
|
/** Reads notifications since the cursor and each Issue or PR they name, advancing the cursor atomically with the retry map. */
|
||||||
|
async function pollNotifications(ctx: SourceContext, round: Round): Promise<{ count: number; interval: number }> {
|
||||||
|
const { state } = ctx
|
||||||
|
const since = state.get(KV.gitea.since) ?? round.started
|
||||||
|
if (state.get(KV.gitea.since) === undefined) state.set(KV.gitea.since, since)
|
||||||
|
const { repository } = ctx.deployment
|
||||||
|
const response = await api(ctx).raw(`notifications?all=true&limit=50&since=${encodeURIComponent(since)}`)
|
||||||
|
const text = await response.text()
|
||||||
|
if (!response.ok) throw new GiteaApiError(response.status, `Gitea answered ${response.status} for notifications`, text.slice(0, 300))
|
||||||
|
const interval = Math.max(60, Number(response.headers.get('x-poll-interval') ?? 60) || 60)
|
||||||
|
const notifications = validItems(NotificationSchema, z.array(z.unknown()).parse(JSON.parse(text)), round.tally)
|
||||||
|
const retries = { ...state.get(KV.gitea.retries) }
|
||||||
|
// Failed items of the previous round run first, then newly notified items in chronological order; each Issue is read once.
|
||||||
|
const items = new Map(Object.entries(retries).map(([number, retry]) => [Number(number), retry]))
|
||||||
|
for (const notification of notifications.reverse()) {
|
||||||
|
if (!sameName(notification.repository.full_name, repository)) continue
|
||||||
|
const address = giteaAddress(notification.subject.url ?? '', repository)
|
||||||
|
if (!address || items.has(address.number)) continue
|
||||||
|
items.set(address.number, { since: [state.get(KV.gitea.cursor(address.number)) ?? since, since].sort().at(-1)!, pr: address.pr })
|
||||||
|
}
|
||||||
|
for (const [number, item] of items) {
|
||||||
|
if (stopped(ctx, round)) return { count: notifications.length, interval }
|
||||||
|
round.read.add(number)
|
||||||
|
try {
|
||||||
|
await readNotified(ctx, round, number, item)
|
||||||
|
delete retries[number]
|
||||||
|
} catch (error) {
|
||||||
|
if (stopped(ctx, round)) return { count: notifications.length, interval }
|
||||||
|
const retry = { ...item, failedAt: item.failedAt ?? round.started, attempts: (item.attempts ?? 0) + 1 }
|
||||||
|
// A permanently unreadable thread must not hold Source health in error forever; its cursor stays for later reads.
|
||||||
|
const abandoned = Date.parse(round.started) - Date.parse(retry.failedAt) >= RETRY_WINDOW_MS
|
||||||
|
failed(ctx, round, number, error, abandoned)
|
||||||
|
if (!deleted(error) && !abandoned) { retries[number] = retry; continue }
|
||||||
|
delete retries[number]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.atomic(() => { state.set(KV.gitea.since, cursorAt(round.started)); state.set(KV.gitea.retries, retries) })
|
||||||
|
return { count: notifications.length, interval }
|
||||||
|
}
|
||||||
|
/** Reads one notified Issue or PR: its body when opened since the cursor, then its comments and, for a PR, its reviews. */
|
||||||
|
async function readNotified(ctx: SourceContext, round: Round, number: number, item: Retry): Promise<void> {
|
||||||
|
const { since } = item
|
||||||
|
const login = agentLogin(ctx)
|
||||||
|
const issue = await api(ctx).json(GiteaIssueSchema, `repos/${ctx.deployment.repository}/issues/${number}`)
|
||||||
|
const isPr = !!issue.pull_request || item.pr
|
||||||
|
if (Date.parse(issue.created_at) >= Date.parse(since) && !sameName(issue.user.login, login)) await ctx.receive(issueTrigger(issue, login))
|
||||||
|
const ref: GiteaRef = { number, isPr, url: issue.html_url, ...(isPr ? { prAuthor: issue.user.login } : {}) }
|
||||||
|
await intakeComments(ctx, round, ref, since)
|
||||||
|
if (isPr) await intakeReviews(ctx, round, ref, since)
|
||||||
|
nameThread(ctx, number, issue.title, issue.html_url)
|
||||||
|
ctx.state.set(KV.gitea.cursor(number), cursorAt(round.started))
|
||||||
|
ctx.threads.patch(`${SOURCE}:${number}`, { pollError: undefined })
|
||||||
|
}
|
||||||
|
async function intakeComments(ctx: SourceContext, round: Round, ref: GiteaRef, since: string): Promise<void> {
|
||||||
|
const path = `repos/${ctx.deployment.repository}/issues/${ref.number}/comments`
|
||||||
|
const comments = validItems(GiteaCommentSchema, await api(ctx).list(path, round.signal), round.tally)
|
||||||
|
const login = agentLogin(ctx)
|
||||||
|
for (const comment of comments) {
|
||||||
|
// Stopping mid-read throws so the thread's cursor stays where it was.
|
||||||
|
if (stopped(ctx, round)) throw new Error('The Gitea Source stopped during the poll')
|
||||||
|
const at = Date.parse(comment.updated_at ?? comment.created_at ?? '')
|
||||||
|
if (!Number.isFinite(at) || at < Date.parse(since)) continue
|
||||||
|
if (sameName(comment.user.login, login)) continue
|
||||||
|
const trigger = commentTrigger(comment, ref, login)
|
||||||
|
if (trigger.text) await ctx.receive(trigger)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function intakeReviews(ctx: SourceContext, round: Round, ref: GiteaRef, since: string): Promise<void> {
|
||||||
|
const path = `repos/${ctx.deployment.repository}/pulls/${ref.number}/reviews`
|
||||||
|
const reviews = validItems(GiteaReviewSchema, await api(ctx).list(path, round.signal), round.tally)
|
||||||
|
const login = agentLogin(ctx)
|
||||||
|
for (const review of reviews) {
|
||||||
|
if (stopped(ctx, round)) throw new Error('The Gitea Source stopped during the poll')
|
||||||
|
const at = Date.parse(review.updated_at ?? review.submitted_at ?? '')
|
||||||
|
if (!Number.isFinite(at) || at < Date.parse(since)) continue
|
||||||
|
if (sameName(review.user.login, login)) continue
|
||||||
|
const trigger = reviewTrigger(review, ref, login)
|
||||||
|
if (trigger.text) await ctx.receive(trigger)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** One repository-wide stream; the stream's own schema parsed its items, so the branch casts to the union member it reads. */
|
||||||
|
interface Stream { id: Scope; observed: (config: GiteaConfig) => boolean; schema: z.ZodType<GiteaIssue | GiteaComment>; path: (repository: string) => string }
|
||||||
|
const STREAMS: Stream[] = [
|
||||||
|
{ id: 'issues', observed: config => config.observeIssues, schema: GiteaIssueSchema,
|
||||||
|
path: repository => `repos/${repository}/issues?state=all` },
|
||||||
|
{ id: 'pulls', observed: config => config.observePulls, schema: GiteaIssueSchema,
|
||||||
|
path: repository => `repos/${repository}/pulls?state=all` },
|
||||||
|
{ id: 'comments', observed: config => config.observeComments, schema: GiteaCommentSchema,
|
||||||
|
path: repository => `repos/${repository}/issues/comments` },
|
||||||
|
]
|
||||||
|
/** Polls the repository-wide streams (new Issues, new PRs, comments); each reads from when switched on, resets when off, and fails independently. */
|
||||||
|
async function pollRepository(ctx: SourceContext, round: Round): Promise<void> {
|
||||||
|
const config = ctx.config<GiteaConfig>(SOURCE)
|
||||||
|
if (!config) return
|
||||||
|
for (const stream of STREAMS) {
|
||||||
|
if (stopped(ctx, round)) return
|
||||||
|
if (!stream.observed(config)) { ctx.state.set(KV.gitea.stream(stream.id), undefined); continue }
|
||||||
|
try { await readStream(ctx, round, stream) }
|
||||||
|
catch (error) { if (!stopped(ctx, round)) round.failures.push(`${stream.id}: ${String(error)}`) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Polls one repository-wide stream since its cursor and steps the cursor to five seconds before the newest item
|
||||||
|
* (`cursorAt`), relying on Trigger ID deduplication so repeated reads are no-ops. Items from before the kind was
|
||||||
|
* switched on or beyond the horizon are ignored.
|
||||||
|
*/
|
||||||
|
async function readStream(ctx: SourceContext, round: Round, stream: Stream): Promise<void> {
|
||||||
|
const name = KV.gitea.stream(stream.id)
|
||||||
|
const stored = ctx.state.get(name)
|
||||||
|
const started: Record<string, string> = { [stream.id]: stored?.started[stream.id] ?? round.started }
|
||||||
|
const horizon = Math.floor((Date.parse(round.started) - POLL_HORIZON_MS) / 3_600_000) * 3_600_000
|
||||||
|
const since = new Date(Math.max(Date.parse(stored?.since ?? round.started), horizon)).toISOString()
|
||||||
|
const items = validItems(stream.schema, await api(ctx).list(stream.path(ctx.deployment.repository), round.signal), round.tally)
|
||||||
|
const login = agentLogin(ctx)
|
||||||
|
const fresh = (at: string | null | undefined) =>
|
||||||
|
Date.parse(at ?? '') >= Math.max(Date.parse(started[stream.id]!), horizon) && Date.parse(at ?? '') >= Date.parse(since)
|
||||||
|
for (const item of items) {
|
||||||
|
// Stopping mid-stream aborts before advancing the cursor so unread items are retried.
|
||||||
|
if (stopped(ctx, round)) throw new Error('The Gitea Source stopped during the poll')
|
||||||
|
if (sameName(item.user.login, login)) continue
|
||||||
|
if (stream.id === 'comments') {
|
||||||
|
// The stream's own schema parsed every item, so the branch decides the member of the union.
|
||||||
|
const comment = item as GiteaComment
|
||||||
|
const number = commentNumber(comment.issue_url ?? '', ctx.deployment.repository)
|
||||||
|
if (!number || !fresh(comment.updated_at ?? comment.created_at)) continue
|
||||||
|
const ref: GiteaRef = { number, isPr: !!comment.pull_request_url, ...(comment.html_url ? { url: comment.html_url } : {}) }
|
||||||
|
const trigger = commentTrigger(comment, ref, login)
|
||||||
|
if (trigger.text) await ctx.receive(trigger)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const issue = item as GiteaIssue
|
||||||
|
if (!fresh(issue.created_at)) continue
|
||||||
|
// `/pulls` items carry no `pull_request` meta; the stream itself names the kind, so pass it to the Trigger builder.
|
||||||
|
await ctx.receive(issueTrigger(issue, login, stream.id === 'pulls'))
|
||||||
|
nameThread(ctx, issue.number, issue.title, issue.html_url)
|
||||||
|
}
|
||||||
|
const newest = Math.max(...items.map(item => Date.parse(item.updated_at ?? '')).filter(Number.isFinite))
|
||||||
|
const moved = Number.isFinite(newest) ? cursorAt(new Date(newest).toISOString()) : since
|
||||||
|
const next = Date.parse(moved) > Date.parse(since) ? moved : since
|
||||||
|
ctx.state.set(name, { since: next, started })
|
||||||
|
}
|
||||||
|
/** Polls subscribed Issue and PR Threads not already read in this round. */
|
||||||
|
export async function pollThreads(ctx: SourceContext, signal?: AbortSignal, round = newRound(signal)): Promise<void> {
|
||||||
|
await pollEach(ctx, SOURCE, () => stopped(ctx, round), async thread => {
|
||||||
|
try { await pollThread(ctx, round, thread) }
|
||||||
|
catch (error) {
|
||||||
|
if (stopped(ctx, round)) throw error
|
||||||
|
if (deleted(error)) round.dropped.push(Number(thread.ref.id)); else round.failures.push(`${thread.key}: ${String(error)}`)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}, thread => round.read.has(Number(thread.ref.id)))
|
||||||
|
}
|
||||||
|
async function pollThread(ctx: SourceContext, round: Round, thread: Thread): Promise<void> {
|
||||||
|
const number = Number(thread.ref.id)
|
||||||
|
if (!number) throw new Error('Subscribed thread has no Gitea Issue or PR number')
|
||||||
|
round.read.add(number)
|
||||||
|
let isPr = thread.meta.isPr
|
||||||
|
if (typeof isPr !== 'boolean' || typeof thread.meta.title !== 'string') {
|
||||||
|
const issue = await api(ctx).json(GiteaIssueSchema, `repos/${ctx.deployment.repository}/issues/${number}`)
|
||||||
|
isPr = !!issue.pull_request
|
||||||
|
ctx.threads.patch(thread.key, { number, isPr, url: issue.html_url })
|
||||||
|
if (issue.title) nameThread(ctx, number, issue.title, issue.html_url)
|
||||||
|
}
|
||||||
|
const since = ctx.state.get(KV.gitea.cursor(number)) ?? thread.createdAt
|
||||||
|
const ref: GiteaRef = { number, isPr: isPr === true, ...(typeof thread.meta.url === 'string' ? { url: thread.meta.url } : {}) }
|
||||||
|
await intakeComments(ctx, round, ref, since)
|
||||||
|
if (isPr === true) await intakeReviews(ctx, round, ref, since)
|
||||||
|
ctx.state.set(KV.gitea.cursor(number), cursorAt(round.started))
|
||||||
|
}
|
||||||
|
function nameThread(ctx: SourceContext, number: number, title: string, url?: string): void {
|
||||||
|
ctx.threads.patch(`${SOURCE}:${number}`, { title })
|
||||||
|
ctx.threads.describe(`${SOURCE}:${number}`, { label: `#${number} ${title}`, ...(url ? { url } : {}),
|
||||||
|
facts: { number: { value: `#${number}` } } })
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ const presetOf = (rows: readonly Row[], id: string) => rows.map(row => row.confi
|
|||||||
|
|
||||||
it('defines the worker preset Executor Sessions run under, with a shell that moves long commands to the background', async () => {
|
it('defines the worker preset Executor Sessions run under, with a shell that moves long commands to the background', async () => {
|
||||||
const rows = await patched()
|
const rows = await patched()
|
||||||
expect(rows.map(row => row.id)).toEqual(['dsh-superagent', 'dsh-superagent-github', 'dsh-superagent-feishu', 'dsh-superagent-worker-preset',
|
expect(rows.map(row => row.id)).toEqual(['dsh-superagent', 'dsh-superagent-github', 'dsh-superagent-feishu', 'dsh-superagent-gitea', 'dsh-superagent-worker-preset',
|
||||||
'dsh-superagent-router-preset'])
|
'dsh-superagent-router-preset'])
|
||||||
const preset = presetOf(rows, WORKER_PRESET)
|
const preset = presetOf(rows, WORKER_PRESET)
|
||||||
expect(preset.id).toBe(WORKER_PRESET)
|
expect(preset.id).toBe(WORKER_PRESET)
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { Engine, key, type Assessor, type Executor, type OutboundMessage, type Trigger } from '../src/core/index.ts'
|
||||||
|
import { KV } from '../src/kv.ts'
|
||||||
|
import { GiteaConfigSchema, apply, giteaKind, giteaThreadContext, pollRound } from '../src/sources/gitea.ts'
|
||||||
|
import { giteaClient, GiteaApiError } from '../src/sources/gitea-api.ts'
|
||||||
|
import { SOURCE, issueTrigger } from '../src/sources/gitea-trigger.ts'
|
||||||
|
import { giteaLink, sendGitea } from '../src/sources/gitea-channel.ts'
|
||||||
|
import { SourceRegistry } from '../src/sources/registry.ts'
|
||||||
|
import { Store } from '../src/app/store.ts'
|
||||||
|
import { Commands } from '../src/infra/commands.ts'
|
||||||
|
import { HealthBoard } from '../src/infra/health.ts'
|
||||||
|
import { testDeployment } from './fixtures/deployment.ts'
|
||||||
|
import { SettingsSchema, settingsPolicy } from '../src/policy.ts'
|
||||||
|
import { sourceContextOf, type SourceContextDeps } from '../src/app/source-context.ts'
|
||||||
|
import { mkdtempSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
// The Gitea API client reads its token from the environment; set a test token so the client builds.
|
||||||
|
process.env.GITEA_TOKEN = 'test-token'
|
||||||
|
|
||||||
|
afterEach(() => { vi.unstubAllGlobals() })
|
||||||
|
|
||||||
|
/** A Gitea host: one store, registry, engine and the `gitea` SourceContext, mirroring the feishu/github fixture. */
|
||||||
|
function host(overrides: Partial<SourceContextDeps> = {}) {
|
||||||
|
const store = new Store(join(mkdtempSync(join(tmpdir(), 'gitea-')), 'db.sqlite'))
|
||||||
|
const settings = SettingsSchema.parse({
|
||||||
|
policy: { sources: { gitea: { allowed: [{ id: 'alice' }], bots: [{ id: 'gitea-bot' }] } } },
|
||||||
|
eval: { mode: 'auto' },
|
||||||
|
sources: { gitea: { enabled: true, config: { url: 'https://gitea.example.com', repository: 'acme/widgets', tokenEnv: 'GITEA_TOKEN' } } },
|
||||||
|
})
|
||||||
|
store.saveSettings(settings)
|
||||||
|
const board = new HealthBoard()
|
||||||
|
const sources = new SourceRegistry(() => store.settings(), board)
|
||||||
|
const deployment = testDeployment({ gitea: { url: 'https://gitea.example.com', tokenEnv: 'GITEA_TOKEN', login: 'agent-bot' } })
|
||||||
|
sources.register(giteaKind)
|
||||||
|
const assessor: Assessor = { request: vi.fn(async () => {}) }
|
||||||
|
const executor: Executor = { start: vi.fn(async () => {}), deliver: vi.fn(async () => {}), stop: vi.fn(async () => {}) }
|
||||||
|
const engine = new Engine({ repo: store, scope: sources, policy: () => settingsPolicy(store.settings(), sources), assessor, executor, outbox: { send: () => {}, status: () => undefined, withdraw: () => {} } })
|
||||||
|
const commands = new Commands(() => deployment, {}, new AbortController().signal)
|
||||||
|
const receive = vi.fn(async (trigger: Trigger) => { const result = engine.receive(trigger); engine.evaluateDue(); return result })
|
||||||
|
const parts: SourceContextDeps = { store, engine, outbox: { send: message => { store.receipts.add(message) } }, commands, sources, deployment,
|
||||||
|
ready: Promise.resolve(), configured: () => true, receive, ...overrides }
|
||||||
|
const value = { ...parts, context: (kind: { kind: string; health: readonly string[] }) => sourceContextOf(parts, kind) }
|
||||||
|
const context = sourceContextOf(parts, { kind: 'gitea', health: ['gitea'] })
|
||||||
|
return { ...value, ...context, health: board.all, store, engine, commands: parts.commands, receive, executor }
|
||||||
|
}
|
||||||
|
/** Routes the Gitea API by path under `/api/v1/` and records every call; `fetch` is stubbed for the test. */
|
||||||
|
function gitea(routes: Record<string, unknown>, headers: Record<string, string> = {}) {
|
||||||
|
const calls: { path: string; query: string; method: string }[] = []
|
||||||
|
vi.stubGlobal('fetch', async (url: string, init?: { method?: string }) => {
|
||||||
|
const u = new URL(url)
|
||||||
|
const path = u.pathname.replace(/^\/api\/v1\//, '')
|
||||||
|
calls.push({ path, query: u.search, method: init?.method ?? 'GET' })
|
||||||
|
const route = routes[path]
|
||||||
|
if (route === undefined) return { status: 404, ok: false, headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, text: async () => 'Not Found' }
|
||||||
|
const value: unknown = typeof route === 'function' ? route(u.search) : route
|
||||||
|
if (value instanceof Error) throw value
|
||||||
|
return { status: 200, ok: true, headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, text: async () => JSON.stringify(value) }
|
||||||
|
})
|
||||||
|
return calls
|
||||||
|
}
|
||||||
|
/** A valid Gitea Issue body; `over` overrides fields. */
|
||||||
|
const issue = (number: number, over: Record<string, unknown> = {}): Record<string, unknown> => ({
|
||||||
|
number, title: `Issue ${number}`, body: '', state: 'open', comments: 0,
|
||||||
|
created_at: '2026-09-08T00:00:00Z', updated_at: '2026-09-08T00:00:00Z',
|
||||||
|
html_url: `https://gitea.example.com/acme/widgets/issues/${number}`, user: { login: 'alice', id: 1 },
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
const triggerOf = (meta: Record<string, unknown>): Trigger => ({ id: 't', source: SOURCE, threads: [{ source: SOURCE, id: '12' }],
|
||||||
|
actor: { source: SOURCE, id: 'alice' }, human: true, at: '2026-09-08T00:00:00Z', text: '', meta })
|
||||||
|
|
||||||
|
describe('GiteaConfigSchema', () => {
|
||||||
|
it('defaults a bare config to a safe baseline', () => {
|
||||||
|
expect(GiteaConfigSchema.parse({})).toEqual({
|
||||||
|
repository: '', url: '', tokenEnv: 'GITEA_TOKEN',
|
||||||
|
pollSeconds: 60, failuresBeforeError: 3,
|
||||||
|
observeIssues: false, observePulls: false, observeComments: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
it('accepts a full config and keeps the values', () => {
|
||||||
|
expect(GiteaConfigSchema.parse({
|
||||||
|
repository: 'acme/widgets', url: 'https://gitea.example.com', tokenEnv: 'GITEA_TOKEN',
|
||||||
|
pollSeconds: 300, failuresBeforeError: 5,
|
||||||
|
observeIssues: true, observePulls: true, observeComments: false,
|
||||||
|
})).toEqual({
|
||||||
|
repository: 'acme/widgets', url: 'https://gitea.example.com', tokenEnv: 'GITEA_TOKEN',
|
||||||
|
pollSeconds: 300, failuresBeforeError: 5,
|
||||||
|
observeIssues: true, observePulls: true, observeComments: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
it('rejects unknown keys (strict) and out-of-range values', () => {
|
||||||
|
expect(() => GiteaConfigSchema.parse({ unknown: 1 })).toThrow()
|
||||||
|
expect(() => GiteaConfigSchema.parse({ pollSeconds: 59 })).toThrow()
|
||||||
|
expect(() => GiteaConfigSchema.parse({ pollSeconds: 3601 })).toThrow()
|
||||||
|
expect(() => GiteaConfigSchema.parse({ failuresBeforeError: 0 })).toThrow()
|
||||||
|
expect(() => GiteaConfigSchema.parse({ failuresBeforeError: 21 })).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('giteaKind', () => {
|
||||||
|
it('exposes the eight setup fields in order', () => {
|
||||||
|
expect(giteaKind.fields.map(field => field.key)).toEqual([
|
||||||
|
'url', 'repository', 'tokenEnv', 'pollSeconds',
|
||||||
|
'failuresBeforeError', 'observeIssues', 'observePulls', 'observeComments',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
it('scope reports what the Source observes for each meta kind', () => {
|
||||||
|
const config = GiteaConfigSchema.parse({ observeIssues: true, observePulls: false, observeComments: true })
|
||||||
|
expect(giteaKind.scope!({ meta: { kind: 'issue' } }, config)).toEqual({ listened: true, observed: true, row: 'issues' })
|
||||||
|
expect(giteaKind.scope!({ meta: { kind: 'pull' } }, config)).toEqual({ listened: true, observed: false, row: 'pulls' })
|
||||||
|
expect(giteaKind.scope!({ meta: { kind: 'conversation' } }, config)).toEqual({ listened: true, observed: true, row: 'comments' })
|
||||||
|
expect(giteaKind.scope!({ meta: { kind: 'unknown' } }, config)).toEqual({ listened: true, observed: false })
|
||||||
|
})
|
||||||
|
it('find names the thread of a #number, a bare number, or an Issue/PR page', () => {
|
||||||
|
expect(giteaKind.find!('#12').threads).toEqual([`${SOURCE}:12`])
|
||||||
|
expect(giteaKind.find!('12').threads).toEqual([`${SOURCE}:12`])
|
||||||
|
expect(giteaKind.find!('https://gitea.example.com/acme/widgets/issues/12').threads).toEqual([`${SOURCE}:12`])
|
||||||
|
expect(giteaKind.find!('https://gitea.example.com/acme/widgets/pulls/12').threads).toEqual([`${SOURCE}:12`])
|
||||||
|
expect(giteaKind.find!('nothing')).toEqual({ threads: [], terms: [] })
|
||||||
|
})
|
||||||
|
it('facts name the current Gitea account', () => {
|
||||||
|
const deployment = testDeployment({ gitea: { url: 'https://gitea.example.com', tokenEnv: 'GITEA_TOKEN', login: 'agent-bot' } })
|
||||||
|
expect(giteaKind.facts!(deployment)).toEqual({ tokenEnv: { zh: '当前账号 @agent-bot', en: 'Current account @agent-bot' } })
|
||||||
|
expect(giteaKind.facts!(testDeployment({ gitea: undefined }))).toEqual({})
|
||||||
|
})
|
||||||
|
it('policy.schema validates the allowed and bots lists', () => {
|
||||||
|
expect(giteaKind.policy!.schema.parse({ allowed: [{ id: 'alice' }], bots: [{ id: 'gitea-bot' }] })).toEqual({ allowed: [{ id: 'alice' }], bots: [{ id: 'gitea-bot' }] })
|
||||||
|
expect(giteaKind.policy!.schema.parse({})).toEqual({ allowed: [] })
|
||||||
|
expect(() => giteaKind.policy!.schema.parse({ allowed: [{ id: 'bad id' }] })).toThrow()
|
||||||
|
expect(() => giteaKind.policy!.schema.parse({ allowed: [{ id: 'ok', extra: 1 }] })).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('apply', () => {
|
||||||
|
it('registers the Gitea kind on a fresh host', () => {
|
||||||
|
const store = new Store(join(mkdtempSync(join(tmpdir(), 'gitea-apply-')), 'db.sqlite'))
|
||||||
|
const board = new HealthBoard()
|
||||||
|
const sources = new SourceRegistry(() => store.settings(), board)
|
||||||
|
// The watcher context reports setupPending (not configured) so the polling loop returns immediately.
|
||||||
|
const ctx = { orchestrator: { sources, context: () => ({ ready: Promise.resolve(), configured: () => false, config: () => undefined, report: () => {} }) },
|
||||||
|
effect: vi.fn((fn: () => unknown) => fn()) }
|
||||||
|
apply(ctx as never)
|
||||||
|
expect(sources.kind('gitea')).toBe(giteaKind)
|
||||||
|
store.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('pollRound', () => {
|
||||||
|
it('reads notifications since the cursor, clamps the poll interval, and takes in a mentioned Issue', async () => {
|
||||||
|
const h = host()
|
||||||
|
h.store.set(KV.gitea.since, '2026-09-07T00:00:00Z')
|
||||||
|
const notification = { repository: { full_name: 'acme/widgets' }, subject: { url: 'https://gitea.example.com/api/v1/repos/acme/widgets/issues/12', type: 'Issue' } }
|
||||||
|
const calls = gitea({
|
||||||
|
notifications: [notification],
|
||||||
|
'repos/acme/widgets/issues/12': issue(12, { body: 'Fix it @agent-bot' }),
|
||||||
|
'repos/acme/widgets/issues/12/comments': [],
|
||||||
|
}, { 'x-poll-interval': '120' })
|
||||||
|
const interval = await pollRound(h)
|
||||||
|
expect(interval).toBe(120)
|
||||||
|
expect(h.receive).toHaveBeenCalledTimes(1)
|
||||||
|
expect(h.receive.mock.calls[0]![0]).toMatchObject({ id: key('gitea-issue', '12'), addressed: 'mention', meta: { number: 12, kind: 'issue' } })
|
||||||
|
expect(calls.map(c => c.path)).toContain('notifications')
|
||||||
|
expect(h.store.get(KV.gitea.since)).toBeTypeOf('string')
|
||||||
|
h.store.close()
|
||||||
|
})
|
||||||
|
it('reads the observed repository streams and takes in new Issues, PRs and comments', async () => {
|
||||||
|
const h = host({ configured: () => true })
|
||||||
|
h.store.set(KV.gitea.since, '2026-09-07T00:00:00Z')
|
||||||
|
// Turn on every stream so the round reads all three.
|
||||||
|
const settings = h.store.settings()
|
||||||
|
h.store.saveSettings({ ...settings, sources: { ...settings.sources, gitea: { enabled: true, config: { url: 'https://gitea.example.com', repository: 'acme/widgets', tokenEnv: 'GITEA_TOKEN', observeIssues: true, observePulls: true, observeComments: true } } } })
|
||||||
|
// The stream's `since` is this round's start, so the items must be dated after it.
|
||||||
|
const now = new Date(Date.now() + 60_000).toISOString()
|
||||||
|
const newIssue = issue(20, { created_at: now })
|
||||||
|
const newPr = issue(30, { created_at: now, pull_request: { url: 'https://gitea.example.com/api/v1/repos/acme/widgets/pulls/30' } })
|
||||||
|
const newComment = { id: 1, body: 'hi @agent-bot', user: { login: 'alice', id: 1 }, created_at: now, issue_url: 'https://gitea.example.com/api/v1/repos/acme/widgets/issues/20' }
|
||||||
|
gitea({
|
||||||
|
notifications: [],
|
||||||
|
'repos/acme/widgets/issues': [newIssue],
|
||||||
|
'repos/acme/widgets/pulls': [newPr],
|
||||||
|
'repos/acme/widgets/issues/comments': [newComment],
|
||||||
|
})
|
||||||
|
await pollRound(h)
|
||||||
|
expect(h.receive).toHaveBeenCalledTimes(3)
|
||||||
|
const received = h.receive.mock.calls.map(call => call[0]!)
|
||||||
|
expect(received.map(t => t.meta.kind).sort()).toEqual(['conversation', 'issue', 'pull'])
|
||||||
|
h.store.close()
|
||||||
|
})
|
||||||
|
it('takes in a new comment and review of a subscribed thread', async () => {
|
||||||
|
const h = host()
|
||||||
|
h.store.threads.save({ key: `${SOURCE}:12`, ref: { source: SOURCE, id: '12' }, createdAt: new Date().toISOString(), subscriptions: ['ticket'], correlated: [], pending: [], meta: { isPr: true } })
|
||||||
|
h.store.set(KV.gitea.since, '2026-09-07T00:00:00Z')
|
||||||
|
h.store.set(KV.gitea.cursor(12), '2026-09-08T00:00:00Z')
|
||||||
|
const comment = { id: 1, body: 'hi @agent-bot', user: { login: 'alice', id: 1 }, created_at: '2026-09-09T00:00:00Z' }
|
||||||
|
const review = { id: 10, body: 'looks good', user: { login: 'carol', id: 3 }, state: 'APPROVED', commit_id: 'abc', submitted_at: '2026-09-09T00:00:00Z' }
|
||||||
|
gitea({
|
||||||
|
notifications: [],
|
||||||
|
'repos/acme/widgets/issues/12': issue(12, { pull_request: { url: 'https://gitea.example.com/api/v1/repos/acme/widgets/pulls/12' } }),
|
||||||
|
'repos/acme/widgets/issues/12/comments': [comment],
|
||||||
|
'repos/acme/widgets/pulls/12/reviews': [review],
|
||||||
|
})
|
||||||
|
// pollRound reads subscribed threads through pollThreads; the thread is subscribed and its cursor is set.
|
||||||
|
const interval = await pollRound(h)
|
||||||
|
expect(interval).toBe(60)
|
||||||
|
expect(h.receive).toHaveBeenCalledTimes(2)
|
||||||
|
const received = h.receive.mock.calls.map(call => call[0]!)
|
||||||
|
expect(received.map(t => t.meta.kind).sort()).toEqual(['conversation', 'review'])
|
||||||
|
h.store.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('giteaThreadContext', () => {
|
||||||
|
it('messageId keys each Gitea message by kind', () => {
|
||||||
|
expect(giteaThreadContext.messageId(triggerOf({ number: 12, kind: 'issue' }))).toBe('12-body')
|
||||||
|
expect(giteaThreadContext.messageId(triggerOf({ number: 12, kind: 'pull' }))).toBe('12-body')
|
||||||
|
expect(giteaThreadContext.messageId(triggerOf({ number: 12, kind: 'review', reviewId: 5 }))).toBe('r5')
|
||||||
|
expect(giteaThreadContext.messageId(triggerOf({ number: 12, kind: 'conversation', commentId: 7 }))).toBe('c7')
|
||||||
|
})
|
||||||
|
it('read returns the body for root and parent parts', async () => {
|
||||||
|
const h = host()
|
||||||
|
gitea({ 'repos/acme/widgets/issues/12': issue(12) })
|
||||||
|
const part = await giteaThreadContext.read(h.commands, triggerOf({ number: 12, kind: 'issue' }), 'root', undefined, h.config('gitea')!)
|
||||||
|
expect(part.messages).toHaveLength(1)
|
||||||
|
expect(part.messages[0]!).toMatchObject({ id: '12-body', from: 'alice', text: 'Issue 12\n\n' })
|
||||||
|
h.store.close()
|
||||||
|
})
|
||||||
|
it('read returns body, comments and (for a PR) reviews in time order', async () => {
|
||||||
|
const h = host()
|
||||||
|
gitea({
|
||||||
|
'repos/acme/widgets/issues/12': issue(12, { pull_request: { url: 'https://gitea.example.com/api/v1/repos/acme/widgets/pulls/12' } }),
|
||||||
|
'repos/acme/widgets/issues/12/comments': [
|
||||||
|
{ id: 1, body: 'First', user: { login: 'alice', id: 1 }, created_at: '2026-09-08T01:00:00Z' },
|
||||||
|
{ id: 2, body: 'Second', user: { login: 'bob', id: 2 }, created_at: '2026-09-08T00:30:00Z' },
|
||||||
|
],
|
||||||
|
'repos/acme/widgets/pulls/12/reviews': [
|
||||||
|
{ id: 10, body: 'Review', user: { login: 'carol', id: 3 }, state: 'APPROVED', commit_id: 'abc', submitted_at: '2026-09-08T00:45:00Z' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const part = await giteaThreadContext.read(h.commands, triggerOf({ number: 12, kind: 'pull' }), 'thread', undefined, h.config('gitea')!)
|
||||||
|
expect(part.messages.map(m => m.id)).toEqual(['12-body', 'c2', 'r10', 'c1'])
|
||||||
|
h.store.close()
|
||||||
|
})
|
||||||
|
it('read paginates 100 messages per page and offers the next cursor', async () => {
|
||||||
|
const h = host()
|
||||||
|
const comments = Array.from({ length: 150 }, (_, i) => ({ id: i + 1, body: `C${i + 1}`, user: { login: 'alice', id: 1 }, created_at: `2026-09-08T00:${String(i % 60).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}Z` }))
|
||||||
|
gitea({
|
||||||
|
'repos/acme/widgets/issues/12': issue(12),
|
||||||
|
// The client pages 100 at a time; serve a slice per page so it stops after the last page.
|
||||||
|
'repos/acme/widgets/issues/12/comments': (query: string) => {
|
||||||
|
const page = Number(new URLSearchParams(query).get('page') ?? 1)
|
||||||
|
return comments.slice((page - 1) * 100, page * 100)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const config = GiteaConfigSchema.parse(h.config('gitea'))
|
||||||
|
const page1 = await giteaThreadContext.read(h.commands, triggerOf({ number: 12, kind: 'issue' }), 'thread', undefined, config)
|
||||||
|
expect(page1.messages).toHaveLength(100)
|
||||||
|
expect(page1.next).toBe('p2')
|
||||||
|
const page2 = await giteaThreadContext.read(h.commands, triggerOf({ number: 12, kind: 'issue' }), 'thread', 'p2', config)
|
||||||
|
expect(page2.messages).toHaveLength(51)
|
||||||
|
h.store.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sendGitea', () => {
|
||||||
|
const message: OutboundMessage = { id: 'm1', channel: { source: SOURCE, thread: `${SOURCE}:12`, address: { number: '12', url: 'https://gitea.example.com/acme/widgets/issues/12' } }, text: 'Hello', kind: 'reply' }
|
||||||
|
it('posts a comment and is idempotent via the marker', async () => {
|
||||||
|
const h = host()
|
||||||
|
const calls = gitea({
|
||||||
|
'repos/acme/widgets/issues/12': issue(12),
|
||||||
|
'repos/acme/widgets/issues/12/comments': [],
|
||||||
|
})
|
||||||
|
expect(await sendGitea(h, message)).toBe('sent')
|
||||||
|
expect(calls.filter(c => c.method === 'POST')).toHaveLength(1)
|
||||||
|
// Second send: the marker is already stored, so no new POST.
|
||||||
|
gitea({
|
||||||
|
'repos/acme/widgets/issues/12': issue(12),
|
||||||
|
'repos/acme/widgets/issues/12/comments': [{ id: 1, body: 'Hello\n\n<!-- dsh-superagent:m1 -->', user: { login: 'agent-bot', id: 1 } }],
|
||||||
|
})
|
||||||
|
expect(await sendGitea(h, message)).toBe('sent')
|
||||||
|
h.store.close()
|
||||||
|
})
|
||||||
|
it('returns gone when the Issue is deleted', async () => {
|
||||||
|
const h = host()
|
||||||
|
gitea({})
|
||||||
|
expect(await sendGitea(h, message)).toBe('gone')
|
||||||
|
h.store.close()
|
||||||
|
})
|
||||||
|
it('returns gone when the Issue is locked (403)', async () => {
|
||||||
|
const h = host()
|
||||||
|
vi.stubGlobal('fetch', async () => { throw new GiteaApiError(403, 'Issue is locked', 'locked') })
|
||||||
|
expect(await sendGitea(h, message)).toBe('gone')
|
||||||
|
h.store.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('giteaLink', () => {
|
||||||
|
it('builds the panel link from the address', () => {
|
||||||
|
expect(giteaLink({ number: '12', url: 'https://gitea.example.com/acme/widgets/issues/12' })).toEqual({ url: 'https://gitea.example.com/acme/widgets/issues/12', label: { zh: 'Gitea #12', en: 'Gitea #12' } })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -6,6 +6,7 @@ import { DatabaseSync } from 'node:sqlite'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
|
||||||
import type { UserMessage } from '@deepseek-ai/dsh-llm'
|
import type { UserMessage } from '@deepseek-ai/dsh-llm'
|
||||||
|
import { SHELL_TOOL } from '../src/app/sessions.ts'
|
||||||
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||||
import { Runtime } from '../src/app/runtime.ts'
|
import { Runtime } from '../src/app/runtime.ts'
|
||||||
import { jobRegistry } from './fixtures/host.ts'
|
import { jobRegistry } from './fixtures/host.ts'
|
||||||
@@ -164,7 +165,7 @@ beforeEach(async () => {
|
|||||||
if (agents.has(sessionId)) return
|
if (agents.has(sessionId)) return
|
||||||
const agentCtx = new Context()
|
const agentCtx = new Context()
|
||||||
const preset = {}
|
const preset = {}
|
||||||
const inherited = ['bash', 'read', 'read_image', 'skill', 'job_output', 'job_list', 'job_kill', 'grep', 'web_search',
|
const inherited = ['bash', 'pwsh', 'read', 'read_image', 'skill', 'job_output', 'job_list', 'job_kill', 'grep', 'web_search',
|
||||||
'ask_user_question', 'plugin_manager', 'schedule_create', 'create_goal', 'session_create', 'session_send_message', 'feishu_doc_edit']
|
'ask_user_question', 'plugin_manager', 'schedule_create', 'create_goal', 'session_create', 'session_send_message', 'feishu_doc_edit']
|
||||||
const sessionTools = new Map<string, RegisteredTool>()
|
const sessionTools = new Map<string, RegisteredTool>()
|
||||||
tools.set(sessionId, sessionTools)
|
tools.set(sessionId, sessionTools)
|
||||||
@@ -1560,10 +1561,10 @@ it('refuses an ended ticket\'s worker its tools, and restricts the main Session
|
|||||||
expect(guards.get(sessionId)!()).toBeUndefined()
|
expect(guards.get(sessionId)!()).toBeUndefined()
|
||||||
runtime.engine.stopTicket(ticket.id)
|
runtime.engine.stopTicket(ticket.id)
|
||||||
expect(guards.get(sessionId)!()).toBe('Ticket ended; new work needs a new authorized ticket')
|
expect(guards.get(sessionId)!()).toBe('Ticket ended; new work needs a new authorized ticket')
|
||||||
expect(restricts.get(runtime.masterId)).toEqual([{ allow: ['skill', 'bash', 'read', 'read_image', 'job_output', 'job_list', 'job_kill'] }])
|
expect(restricts.get(runtime.masterId)).toEqual([{ allow: ['skill', SHELL_TOOL, 'read', 'read_image', 'job_output', 'job_list', 'job_kill'] }])
|
||||||
// A worker keeps only the profile's work tools: none that ask a person, change the profile, schedule or start work outside
|
// A worker keeps only the profile's work tools: none that ask a person, change the profile, schedule or start work outside
|
||||||
// the worker slots, reach other Sessions or edit documents, whenever they were registered.
|
// the worker slots, reach other Sessions or edit documents, whenever they were registered.
|
||||||
expect(restricts.get(sessionId)).toEqual([{ allow: ['read', 'read_image', 'grep', 'bash', 'job_output', 'job_list', 'job_kill', 'web_search', 'skill'] }])
|
expect(restricts.get(sessionId)).toEqual([{ allow: ['read', 'read_image', 'grep', SHELL_TOOL, 'job_output', 'job_list', 'job_kill', 'web_search', 'skill'] }])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('gives an explicit ticket\'s worker a prompt without the limits of an inferred Mandate', async () => {
|
it('gives an explicit ticket\'s worker a prompt without the limits of an inferred Mandate', async () => {
|
||||||
@@ -1717,7 +1718,7 @@ it('gives a subagent of a worker only the worker\'s work tools, and none once th
|
|||||||
const child = agents.get('child-1')! as unknown as { session: { header: { parentSession?: string } } }
|
const child = agents.get('child-1')! as unknown as { session: { header: { parentSession?: string } } }
|
||||||
child.session.header = { parentSession: sessionId }
|
child.session.header = { parentSession: sessionId }
|
||||||
await (runtime as unknown as { ctx: Context }).ctx.parallel('agent/created', { agent: child } as never)
|
await (runtime as unknown as { ctx: Context }).ctx.parallel('agent/created', { agent: child } as never)
|
||||||
expect(restricts.get('child-1')).toEqual([{ allow: ['read', 'read_image', 'grep', 'bash', 'job_output', 'job_list', 'job_kill', 'web_search', 'skill'] }])
|
expect(restricts.get('child-1')).toEqual([{ allow: ['read', 'read_image', 'grep', SHELL_TOOL, 'job_output', 'job_list', 'job_kill', 'web_search', 'skill'] }])
|
||||||
expect(guards.get('child-1')!()).toBeUndefined()
|
expect(guards.get('child-1')!()).toBeUndefined()
|
||||||
runtime.engine.stopTicket(ticket.id)
|
runtime.engine.stopTicket(ticket.id)
|
||||||
expect(guards.get('child-1')!()).toBe('Ticket ended; new work needs a new authorized ticket')
|
expect(guards.get('child-1')!()).toBe('Ticket ended; new work needs a new authorized ticket')
|
||||||
@@ -1739,7 +1740,7 @@ it('gives a subagent of a worker\'s subagent the same work tools and guard', asy
|
|||||||
agent.session.header = { parentSession: parent }
|
agent.session.header = { parentSession: parent }
|
||||||
await created.parallel('agent/created', { agent } as never)
|
await created.parallel('agent/created', { agent } as never)
|
||||||
}
|
}
|
||||||
expect(restricts.get('grandchild-1')).toEqual([{ allow: ['read', 'read_image', 'grep', 'bash', 'job_output', 'job_list', 'job_kill', 'web_search', 'skill'] }])
|
expect(restricts.get('grandchild-1')).toEqual([{ allow: ['read', 'read_image', 'grep', SHELL_TOOL, 'job_output', 'job_list', 'job_kill', 'web_search', 'skill'] }])
|
||||||
runtime.engine.stopTicket(ticket.id)
|
runtime.engine.stopTicket(ticket.id)
|
||||||
expect(guards.get('grandchild-1')!()).toBe('Ticket ended; new work needs a new authorized ticket')
|
expect(guards.get('grandchild-1')!()).toBe('Ticket ended; new work needs a new authorized ticket')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
const t = fs.readFileSync('src/sources/gitea-trigger.ts', 'utf8').split('\n')
|
||||||
|
console.log('L14:', JSON.stringify(t[13]))
|
||||||
|
console.log('L54:', JSON.stringify(t[53]))
|
||||||
|
console.log('L89:', JSON.stringify(t[88]))
|
||||||
|
const g = fs.readFileSync('src/sources/gitea.ts', 'utf8').split('\n')
|
||||||
|
for (const i of [39, 45, 46]) console.log('gitea L' + (i + 1) + ':', JSON.stringify(g[i]))
|
||||||
|
const a = fs.readFileSync('src/sources/gitea-api.ts', 'utf8').split('\n')
|
||||||
|
console.log('api L20:', JSON.stringify(a[19]))
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Collapses every run of 2+ literal backslashes to one in the gitea source files (over-escaped writes).
|
||||||
|
import fs from 'node:fs'
|
||||||
|
const B = String.fromCharCode(92)
|
||||||
|
const files = ['gitea-api.ts', 'gitea.ts', 'gitea-trigger.ts', 'gitea-channel.ts']
|
||||||
|
for (const f of files) {
|
||||||
|
const path = 'src/sources/' + f
|
||||||
|
const before = fs.readFileSync(path, 'utf8')
|
||||||
|
let after = before
|
||||||
|
while (after.includes(B + B)) after = after.split(B + B).join(B)
|
||||||
|
if (after !== before) {
|
||||||
|
fs.writeFileSync(path, after)
|
||||||
|
console.log(f, 'collapsed', (before.length - after.length), 'bytes')
|
||||||
|
} else console.log(f, 'clean')
|
||||||
|
}
|
||||||
|
for (const f of files) {
|
||||||
|
const t = fs.readFileSync('src/sources/' + f, 'utf8')
|
||||||
|
const remaining = t.split(B + B).length - 1
|
||||||
|
const quotes = t.split(B + String.fromCharCode(39)).length - 1
|
||||||
|
console.log(f, 'remaining double:', remaining, 'escaped-quote sites:', quotes)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
const files = ['gitea-trigger.ts', 'gitea-api.ts', 'gitea-channel.ts', 'gitea.ts']
|
||||||
|
for (const f of files) {
|
||||||
|
try { await import('./src/sources/' + f); console.log(f, 'IMPORT OK') }
|
||||||
|
catch (e) { console.log(f, 'FAIL', e.constructor.name, String(e.message).split('\n')[0], 'at line', (e.stack ?? '').split('\n').find(l => l.includes(f) && /:\d+:\d+/.test(l)) ?? '?') }
|
||||||
|
}
|
||||||
+1
-1
@@ -7,7 +7,7 @@ const raw = { name: 'raw',
|
|||||||
load: (id: string) => id.endsWith('?raw') ? { code: `export default ${JSON.stringify(readFileSync(id.slice(0, -4), 'utf8'))}`, moduleType: 'js' as const }
|
load: (id: string) => id.endsWith('?raw') ? { code: `export default ${JSON.stringify(readFileSync(id.slice(0, -4), 'utf8'))}`, moduleType: 'js' as const }
|
||||||
: undefined }
|
: undefined }
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
{ entry: { index: 'src/index.ts', 'github-watch': 'src/sources/github.ts', 'feishu-watch': 'src/sources/feishu.ts' }, outDir: 'lib', format: 'esm', fixedExtension: false, platform: 'node', target: 'es2024', dts: true, clean: true, deps: { neverBundle: [/^@deepseek-ai\//, 'zod', 'proper-lockfile'] } },
|
{ entry: { index: 'src/index.ts', 'github-watch': 'src/sources/github.ts', 'feishu-watch': 'src/sources/feishu.ts', 'gitea-watch': 'src/sources/gitea.ts' }, outDir: 'lib', format: 'esm', fixedExtension: false, platform: 'node', target: 'es2024', dts: true, clean: true, deps: { neverBundle: [/^@deepseek-ai\//, 'zod', 'proper-lockfile'] } },
|
||||||
{ entry: { client: 'src/client/index.tsx' }, outDir: 'lib', format: 'cjs', platform: 'browser', dts: false, clean: false, plugins: [raw],
|
{ entry: { client: 'src/client/index.tsx' }, outDir: 'lib', format: 'cjs', platform: 'browser', dts: false, clean: false, plugins: [raw],
|
||||||
deps: { neverBundle: ['react', 'react/jsx-runtime', 'react-dom'] },
|
deps: { neverBundle: ['react', 'react/jsx-runtime', 'react-dom'] },
|
||||||
outputOptions: { entryFileNames: 'client.js', banner: 'window.__ModuleLoader__.load({ id: "dsh-superagent", factory: (require) => {', footer: 'return module.exports; } });', intro: 'var module = { exports: {} }; var exports = module.exports;' } },
|
outputOptions: { entryFileNames: 'client.js', banner: 'window.__ModuleLoader__.load({ id: "dsh-superagent", factory: (require) => {', footer: 'return module.exports; } });', intro: 'var module = { exports: {} }; var exports = module.exports;' } },
|
||||||
|
|||||||
Reference in New Issue
Block a user