diff --git a/README.ja.md b/README.ja.md index 065bacd90..f044c4883 100644 --- a/README.ja.md +++ b/README.ja.md @@ -78,7 +78,7 @@ npm install -g teamai-cli ### チーム管理者 / 個人利用 -Git ホスト(GitHub、GitLab、GitCode、CNB、TGit、またはプライベート Git サービス)に共有リポジトリを作成します。メンバーにはブランチへの push 権限を付け、デフォルトブランチは保護したままにします。その後 `teamai init https://github.com/your-org/your-repo` を実行します。 +Git ホスト(GitHub、GitLab、GitCode、Gitea、CNB、TGit、またはプライベート Git サービス)に共有リポジトリを作成します。メンバーにはブランチへの push 権限を付け、デフォルトブランチは保護したままにします。その後 `teamai init https://github.com/your-org/your-repo` を実行します。 > **まだチームリポジトリがない場合は?** 本番向けの skills、rules、review agents が入ったテンプレートから始められます。[teamai-hub](https://github.com/teamai-hub) org を開き、**Fork** してから、新しいリポジトリに対して `teamai init` を実行してください。 diff --git a/README.ko.md b/README.ko.md index 2d9a99b43..31f88a8fb 100644 --- a/README.ko.md +++ b/README.ko.md @@ -78,7 +78,7 @@ npm install -g teamai-cli ### 팀 관리자 / 개인 사용자 -Git 호스트(GitHub, GitLab, GitCode, CNB, TGit 또는 비공개 Git 서비스)에 공유 저장소를 만듭니다. 팀원에게는 브랜치 push 권한을 주고, 기본 브랜치는 보호된 채로 둡니다. 그다음 `teamai init https://github.com/your-org/your-repo`를 실행하세요. +Git 호스트(GitHub, GitLab, GitCode, Gitea, CNB, TGit 또는 비공개 Git 서비스)에 공유 저장소를 만듭니다. 팀원에게는 브랜치 push 권한을 주고, 기본 브랜치는 보호된 채로 둡니다. 그다음 `teamai init https://github.com/your-org/your-repo`를 실행하세요. > **아직 팀 저장소가 없나요?** 프로덕션에 바로 쓸 수 있는 Skills, Rules, review agents가 미리 들어 있는 템플릿으로 시작하세요. [teamai-hub](https://github.com/teamai-hub) org를 둘러본 뒤 **Fork**하고, 새로 만든 저장소에 `teamai init`을 실행하면 됩니다. diff --git a/README.md b/README.md index 7a3e4aad9..51bb75a6c 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ npm install -g teamai-cli ### Team admin / solo user -Create a shared repo on your git host (GitHub, GitLab, GitCode, CNB, TGit, or a private Git service). Give teammates permission to push branches, and leave the default branch protected. Then run `teamai init https://github.com/your-org/your-repo`. +Create a shared repo on your git host (GitHub, GitLab, GitCode, Gitea, CNB, TGit, or a private Git service). Give teammates permission to push branches, and leave the default branch protected. Then run `teamai init https://github.com/your-org/your-repo`. > **No team repo yet?** Start from a template pre-loaded with production-ready skills, rules, and review agents. Browse the [teamai-hub](https://github.com/teamai-hub) org, click **Fork**, then `teamai init` against your new repo. diff --git a/README.th.md b/README.th.md index 23396df91..33c83f39f 100644 --- a/README.th.md +++ b/README.th.md @@ -78,7 +78,7 @@ npm install -g teamai-cli ### ผู้ดูแลทีม / ผู้ใช้คนเดียว -สร้างรีโปที่ใช้ร่วมกันบน Git host (GitHub, GitLab, GitCode, CNB, TGit หรือบริการ Git ส่วนตัว) ให้สมาชิกสิทธิ์ push สาขา และปล่อยให้สาขาหลักถูกป้องกันไว้ จากนั้นรัน `teamai init https://github.com/your-org/your-repo` +สร้างรีโปที่ใช้ร่วมกันบน Git host (GitHub, GitLab, GitCode, Gitea, CNB, TGit หรือบริการ Git ส่วนตัว) ให้สมาชิกสิทธิ์ push สาขา และปล่อยให้สาขาหลักถูกป้องกันไว้ จากนั้นรัน `teamai init https://github.com/your-org/your-repo` > **ยังไม่มีรีโปของทีม?** เริ่มจากเทมเพลตที่มี Skills, Rules และ review agents พร้อมใช้จริงอยู่แล้ว เปิดดู org [teamai-hub](https://github.com/teamai-hub) กด **Fork** แล้วรัน `teamai init` กับรีโปใหม่ของคุณ diff --git a/README.zh-CN.md b/README.zh-CN.md index f2cc9e618..77ac05342 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -84,7 +84,7 @@ npm install -g teamai-cli ### 团队管理员 / 个人使用者 -在 Git 托管平台(GitHub、GitLab、GitCode、CNB、TGit,或私有 Git 服务)创建共享仓库。给同事分支写权限,主干保持保护。然后运行 `teamai init https://github.com/your-org/your-repo`。 +在 Git 托管平台(GitHub、GitLab、GitCode、Gitea、CNB、TGit,或私有 Git 服务)创建共享仓库。给同事分支写权限,主干保持保护。然后运行 `teamai init https://github.com/your-org/your-repo`。 > **还没有团队仓库?** 可以从内置了成套 skills、rules、review agents 的模板起步。浏览 [teamai-hub](https://github.com/teamai-hub) org,点 **Fork** 生成自己的仓库,再对它执行 `teamai init`。 diff --git a/docs/designs/README.md b/docs/designs/README.md index e6af06d9c..770ad1151 100644 --- a/docs/designs/README.md +++ b/docs/designs/README.md @@ -6,6 +6,7 @@ One document per feature design, in English. The status column says how far each | --- | --- | --- | | Implemented | [Data directory layout](data-directory-layout.md) — global home + per-project partitioning | #374 | | Implemented | [Unified dashboard](dashboard-unified.md) — four-module local dashboard | — | +| In progress | [Gitea provider](gitea-provider.md) — self-hosted authentication, PRs and review discussions | — | | Implemented | [GitCode provider](gitcode-provider.md) | #361 | | Implemented | [Model profiles](model-profiles.md) — `teamai models` | — | | Implemented | [Team env by directory](env-by-directory.md) — each directory's shell gets the env of the scope that governs it | #1018 | diff --git a/docs/designs/gitea-provider.md b/docs/designs/gitea-provider.md new file mode 100644 index 000000000..86ca55bc8 --- /dev/null +++ b/docs/designs/gitea-provider.md @@ -0,0 +1,145 @@ +# Gitea provider + +> Status: In progress +> Baselines: v0.27.0 for the backport; main for review discussions. + +## Background and scope + +An unrecognized self-hosted Gitea remote previously selected the transport-only +Git provider. Branches could be pushed but PR creation remained manual. This +provider adds explicit instance configuration, PAT authentication, PR creation +and reviewers, repository/org operations, and PR import without changing the +core push algorithm or introducing another platform CLI. + +## Data flow and ownership + +```text +init URL → registry → GiteaProvider → verified login → private Git clone +push branch → createPullRequest → recover exact PR → request reviewers +import PR URL → configured-origin validation → metadata/commits/diff → MRData +shallow import → validated clone URL → one-off Basic header → Git +``` + +`repo-url.ts` owns instance validation and URL parsing. `gitea-api.ts` owns PAT +resolution, per-process login caching, bounded HTTP requests and Git credentials. +`org.ts` maps organization repositories; `mr-fetch.ts` maps evidence for import; +`probe.ts` performs anonymous detection; `index.ts` implements GitProvider. + +The registry, provider exports/schema, init help, doctor, shallow clone/fetch, +MR dispatch, and init clone target are connected explicitly. Init must pass the +canonical remote to Gitea rather than reducing it to owner/repo, preserving SSH +transport and port. Both shallow-auth callers await login resolution. + +## Shared state review + +| State | Writers | Readers | +| --- | --- | --- | +| Instance/token environment | User shell or process launcher; the provider does not persist env changes | `repo-url.ts`, `getGiteaToken`, API/Git authentication, registry and doctor | +| Current/legacy netrc | Interactive `authenticate` calls `writeGiteaNetrcToken` for the configured host; existing GitCode login and external `gf auth login` share this file | Gitea `getGiteaToken`, existing GitCode/TGit credential readers, and Git's HTTP transport; the legacy location is read-only for Gitea | +| Process login map | `giteaWhoami` after a successful `/user` response | `giteaWhoami` and synchronous `giteaRepoClone`; key includes instance and token | +| Clone origin | Git clone writes the validated transport URL; an operator replaces persisted HTTP credentials during rotation | Existing pull/push Git commands; shallow fetch and cached-head lookup validate the origin before attaching Gitea authentication | +| Local/team provider selection | Existing init/config persistence and an explicit team-manifest migration | Provider schema/registry, init, push, clone/import and doctor; this change adds the enum and wiring, not another config store | +| Remote PR/reviewer state | `giteaPullCreate` creates PRs and requests reviewers separately | Duplicate recovery reads exact head/base and repository identities; MR import reads metadata, commits, diff and optional reviews/comments | +| Normalized discussions | Main `mr-fetch.ts` maps independent source comments and `pruneThreads` filters them | `formatMrThreads` and the learning prompt; the 0.27 backport does not produce discussions | + +The netrc hostname namespace intentionally follows the existing providers and +cannot separate two services on the same host by port. Such services require +separate user directories or explicit env tokens; stored Git origins still need +an independent credential update. + +## Instance and credential boundaries + +`GITEA_URL` is required and preserves scheme, port and subpath. Optional +`TEAMAI_GITEA_HOST` is a same-host routing hint, not an alias map. HTTP inputs +are validated against origin and base-path segments before any credential is +used. SSH keeps the supplied port and requires the same hostname. Anonymous +version probing has a three-second total budget, omits credentials and rejects +redirects. A compatible response does not establish Forgejo identity. + +PAT precedence is env, then a matching hostname in netrc. Interactive init uses +the existing hidden secret prompt and verifies before saving mode 0600. The +verified login is cached only per instance/token in the current process. REST +uses token auth; HTTP Git uses Basic with that login. Private team HTTP origins +persist encoded credentials for bare push; SSH origins never receive a PAT. +Shallow clones/fetches use one-off headers. Netrc is not port-scoped; token/TLS +migration must update existing origins separately. Errors redact raw and encoded +credentials and authorization headers. + +The synchronous clone/org-existence contract is retained: rare synchronous API +lookups use a bounded Node child, passing the PAT over stdin instead of argv. +Init normally has already verified and cached the login before cloning. + +## PR and evidence behavior + +- Create uses head/base/title/body. Duplicate or ambiguous transport failure + recovery enumerates open PRs and matches both full repository names and refs. + Only one exact match is accepted; authorization/server errors are not retried. +- Gitea 1.27 accepts reviewers in the create body. Separate per-login requests + are intentional: an invalid reviewer cannot prevent PR creation or discard + other valid requests. Existing invitations are recognized; failures are visible + warnings and independent of the returned PR URL. +- Pagination honors next links and otherwise continues to an empty page; a + short page alone is not terminal. Cross-endpoint next links are rejected. + Reaching a resource budget fails or reports truncation instead of silently + claiming complete evidence. +- Required PR metadata/commits/diff failures stop import. The diff is limited + to 50 KiB on read, with a UTF-8-safe incompleteness marker. The 0.27 backport + does not read discussions. Main reads review summaries, inline comments and issue comments using MRFetchOptions; each retains its source ID and timestamp. Gitea exposes no reply-parent ID or authoritative source line in that contract, so comments remain independent and diff positions are not presented as line numbers. The extraction prompt labels missing resolution state explicitly and forbids inferring resolved status or agreements from missing metadata or a merged PR. A real-model draft exposed that risk during validation. Pending reviews and generated TeamAI comments are excluded; optional API failures warn and preserve other evidence. +- A private repository's 404 is ambiguous, so clone failure does not trigger + automatic repository creation. Organization/repository creation is explicit. + +## Verification + +Initial unit and integration checks cover URL forms, host/port/subpath rejection, +env/netrc/TTY auth, PR duplicate recovery, reviewer partial failure, pagination, +bounded diff, SSH-origin preservation, org mapping and redaction. Tests and +real-CLI records must name the exact build before the feature is declared ready. +Live tests require an explicit opt-in and dedicated test accounts. + +Local verification on 2026-10-19 used Node 22 and isolated Git 2.54: + +- Main `4a047e8` passed typecheck, lint, build and the full unit run: + 11,077 tests passed, 25 skipped. The 0.27 backport at `30eb00f` passed the + same checks with 8,208 tests passed, 19 skipped. Each source tree stayed + unchanged during its checks. An earlier main run failed because an old + whole-module test mock omitted the new diagnostic helper; it now preserves + the real exports and only replaces network/AI extraction. +- The Gitea directory's focused coverage at `4d23906` is 97.62% statements + and lines, 84.41% branches and 89.36% functions (directory aggregates). +- Gitea 1.27.3 fixtures exercised packaged init and explicit-port SSH push + on both branches. The 0.27 backport also exercised HTTP PR creation, + repeated PR reuse and reviewer assignment. Main's complete HTTP + init/push/repeat record and final-head service-interaction reruns are still + pending; SSH and Forgejo records do not substitute for them. Separate API + fixtures checked 70 commits, 140 unique review/comment bodies, exact PR + recovery, token revocation/rotation, anonymous public fetch and private denial. +- Forgejo 15.0.10 fixtures exercised packaged init, push, PR reuse and + reviewer assignment. Both servers were also used for actual CLI import + with a real Claude backend; this is a bounded compatibility sample. +- The `4d23906` package (SHA256 + `501192853b24e0fd6041074276b11d9567fab75897f1d109e999e3d8f8aa008e`) + imported a merged Gitea PR whose repository name contains `github.com`. + CLI and model exited 0, and all three discussion sources reached the prompt. + The preceding package selected GitHub for that same URL and failed before AI. + +Rejected-URL diagnostics were also exercised with synthetic URL credentials, +query parameters and fragments. At `73ac81a`, packaged single and batch CLI +errors retained only the safe host/path. After the Docker daemon disconnected, +the main legacy-parser collision was verified with the native built CLI at +`5dc9ff1`, in a temporary project using dry-run/output without overriding HOME. +Its JavaScript matched the retained package byte for byte. The 0.27 package +passed both CLI rejection paths in Docker. Interrupted Docker container state +and service-interaction reruns still require the daemon to be restored. + +Model output remains a draft requiring review: earlier runs inferred resolved +status without evidence or confused the CLI and server versions. The prompt +now guards unknown resolution state; a successful import is not proof of content +accuracy. Target-host deployment, upstream CI, release publication and actual +member acceptance remain outside these local results. + +## Non-goals + +Anonymous team init, SSH hostname aliases, gitea.com global mapping, Gitea Actions +templates, and CI extraction/comment commands are excluded. A credential helper +may replace embedded HTTP origins later; this provider does not redesign every +existing provider's credential storage. diff --git a/docs/guide/providers.md b/docs/guide/providers.md index c0feaa55e..cc2a3c820 100644 --- a/docs/guide/providers.md +++ b/docs/guide/providers.md @@ -4,13 +4,14 @@ --- -TeamAI CLI talks to Git hosting platforms through a provider layer. Six providers are implemented: +TeamAI CLI talks to Git hosting platforms through a provider layer. Seven providers are implemented: | Provider | Host | Authentication | Suggested for | |----------|------|----------------|---------------| | `github` | github.com | `gh` CLI or the `GITHUB_TOKEN` environment variable | Open source projects, external users | | `cnb` | cnb.cool | `cnb login` or the `CNB_TOKEN` environment variable | CNB (Cloud Native Build) users | | `gitlab` | gitlab.com or a self-hosted instance | `GITLAB_TOKEN` environment variable | GitLab, including enterprise self-hosted | +| `gitea` | configured self-hosted instance | `GITEA_TOKEN` or a hidden token prompt | Gitea PR workflow | | `gitcode` | gitcode.com | `GITCODE_TOKEN` environment variable, or paste a token during init | GitCode (CSDN) users | | `git` | any Git host | The system Git credential helper or an SSH key | Self-hosted Gitea and other platforms | | `tgit` | git.woa.com | `gf` CLI (downloaded automatically) + `~/.netrc` | Tencent internal teams | @@ -43,7 +44,7 @@ After a successful init the provider is written to the `provider` field of the t teamai init https://gitlab.example.com/group/repo --provider git ``` -Values: `tgit`, `github`, `cnb`, `gitlab`, `gitcode`, `git`. `git` needs no platform token and uses your existing Git credentials; open PRs/MRs on the Git host yourself. The choice only affects your machine. A self-hosted GitLab with `--provider gitlab` still needs `GITLAB_URL` and `GITLAB_TOKEN`. +Values: `tgit`, `github`, `cnb`, `gitlab`, `gitcode`, `gitea`, `git`. `git` needs no platform token and uses your existing Git credentials; open PRs/MRs on the Git host yourself. The choice only affects your machine. A self-hosted GitLab with `--provider gitlab` still needs `GITLAB_URL` and `GITLAB_TOKEN`. ## Generic Git provider (self-hosted and private repos) @@ -233,7 +234,7 @@ GitCode sets no default email domain and uses the user's global git configuratio ## Setting the provider in `teamai.yaml` -Besides URL auto-detection, the team repo's `teamai.yaml` can name the provider explicitly with `provider: github`, `provider: tgit`, `provider: cnb`, `provider: gitlab`, `provider: gitcode` or `provider: git`. A typical `teamai.yaml`: +Besides URL auto-detection, the team repo's `teamai.yaml` can name the provider explicitly with `provider: github`, `provider: tgit`, `provider: cnb`, `provider: gitlab`, `provider: gitcode`, `provider: gitea` or `provider: git`. A typical `teamai.yaml`: ```yaml team: my-team @@ -248,3 +249,95 @@ reviewers: ## Adding a provider Provider internals and the steps for adding a new one are in [Adding a Git provider](../dev/adding-a-provider.md). + + +## Gitea provider (self-hosted) + +The `gitea` provider uses the instance REST API and Git directly. It needs no +separate platform CLI. Configure the full instance URL before joining a team: + +```bash +export GITEA_URL=https://gitea.example.com +teamai init https://gitea.example.com/your-org/team.git --scope user +``` + +An unknown HTTP(S) host can be probed anonymously for a compatible version +endpoint. This only prints a configuration hint; it never authorizes sending a +token to that host. Set `GITEA_URL` and retry. This build does not map gitea.com +globally. Local Forgejo 15.0.10 verification covered init, push, PR creation, +reviewer assignment and PR import using a real AI backend. Main also checked +review discussions; the 0.27 backport does not read them. This tested subset +does not establish compatibility with every Forgejo version or deployment. + +### Authentication and URLs + +Set `GITEA_TOKEN` to a Personal Access Token, or paste one at the hidden terminal +prompt during `init`. The token is verified with `GET /api/v1/user` before it is +saved to `~/.netrc` with mode `0600`. Non-interactive initialization requires an +existing env/netrc token and fails immediately when none is configured. An invalid +configured token fails with an actionable error rather than restarting login. + +The token needs access to the repository, PRs/reviewers/comments, the user profile, +and any organization operations you use. Token scopes vary by server version: +verify them against that instance's API documentation. An administrator should +create the organization and team repository before member onboarding; a private +repository's ambiguous 404 is not treated as permission to create another one. + +`GITEA_URL` preserves its HTTP(S) scheme, port and reverse-proxy subpath. HTTP +repository/PR URLs must match that complete origin and base path. Optional +`TEAMAI_GITEA_HOST` must name the same host, with the same HTTP port when supplied. +The SSH port may differ, but SSH aliases with a different hostname are not supported: + +```bash +export GITEA_URL=https://gitea.example.com/sub/path +teamai init ssh://git@gitea.example.com:2222/your-org/team.git --scope user +``` + +SSH initialization still uses the PAT for platform identity; the clone and later +Git operations keep the SSH URL and use your key. For shallow imports with `--ssh`, +provide an explicit SSH URL including the port, rather than an HTTP URL whose SSH +port the CLI would have to guess. + +REST uses `Authorization: token `. HTTP Git uses Basic authentication with +the verified login and the token as password. The private team clone persists +those credentials in its origin URL so later bare `git push` works. Treat that +clone as credential-bearing. Shallow source clones use a one-off header instead. +Changing the env/netrc token does not replace a token already embedded in an +origin: update that private clone's credentials and verify both API and Git access. +TLS migration also requires updating saved remotes, not just `GITEA_URL`. + +Netrc matches hostname, not port. Separate user directories or explicit credentials +are needed when several authenticated services share a hostname. Do not let test +instances overwrite another service's netrc entry. + +### Sharing, import and permissions + +`teamai push --all` pushes a branch, creates a PR, and requests the configured +reviewers by Gitea login. An existing PR is recovered using both repositories +and both branch refs, not only the head branch. Reviewers are requested separately: +a bad login prints a warning but does not discard the PR or other valid requests. + +For a protected default branch, an administrator first initializes the TeamAI +skeleton and reviewer configuration, then enables protection. Members need write +access to their push branches and the reports/learnings branches, plus PR access; +ordinary membership does not need permission to push directly to main. + +This provider supports `teamai import --from-mr /org/repo/pulls/N` +for PR metadata, paginated commits and a bounded diff. A diff over 50 KiB is marked +incomplete. Required evidence failures stop import rather than yielding an empty +diff. Review discussions are included by default; disable them with the import discussion option. Individual discussion failures warn while preserving required PR evidence. Gitea CI +extraction/comment commands and Gitea Actions templates are outside this provider. + +Anonymous public repositories can be shallow-cloned and fetched. This does not +provide anonymous `teamai init`, member registration or push. `teamai doctor` +checks the configured URL and presence of a token; an actual API request verifies +whether the token is accepted. + +### Migrating a generic Git team + +Install a Gitea-capable build for all members before changing the shared manifest +to `provider: gitea`. A member can try the new provider first with +`teamai init --provider gitea` while the team still uses `provider: git`. +An explicit local provider choice outranks the team manifest; reinitialize members +that previously opted into `--provider git`. Merge the team configuration change +through a PR and verify a regular member can create and update a PR automatically. diff --git a/docs/guide/zh-CN/providers.md b/docs/guide/zh-CN/providers.md index bc29b46a2..68e7df64b 100644 --- a/docs/guide/zh-CN/providers.md +++ b/docs/guide/zh-CN/providers.md @@ -4,13 +4,14 @@ --- -TeamAI CLI 通过 provider 抽象层支持多个 Git 托管平台。当前实现了六个: +TeamAI CLI 通过 provider 抽象层支持多个 Git 托管平台。当前实现了七个: | Provider | Host | 认证方式 | 建议场景 | |----------|-----------------|--------------------------------------|----------------------| | `github` | github.com | `gh` CLI 或 `GITHUB_TOKEN` 环境变量 | 开源项目、外部用户 | | `cnb` | cnb.cool | `cnb login` 或 `CNB_TOKEN` 环境变量 | CNB(云原生构建)用户 | | `gitlab` | gitlab.com 或自托管实例 | `GITLAB_TOKEN` 环境变量 | GitLab / 企业自托管 | +| `gitea` | 已配置的自托管实例 | `GITEA_TOKEN` 环境变量或隐藏输入 PAT | Gitea PR 协作 | | `gitcode`| gitcode.com | `GITCODE_TOKEN` 环境变量或 init 交互粘贴 | GitCode(CSDN)用户 | | `git` | 任意 Git host | 系统 Git Credential Helper 或 SSH Key | 自建 Gitea 等其他平台 | | `tgit` | git.woa.com | `gf` CLI(自动下载)+ `~/.netrc` | 腾讯内部团队 | @@ -43,7 +44,7 @@ git@git.example.com:group/repo.git → git teamai init https://gitlab.example.com/group/repo --provider git ``` -取值:`tgit`、`github`、`cnb`、`gitlab`、`gitcode`、`git`。`git` 不需要平台 token,用已有的 Git 凭据;PR/MR 需要自己到 Git 平台上创建。该选择只影响本机。自建 GitLab 用 `--provider gitlab` 时仍需设置 `GITLAB_URL` 和 `GITLAB_TOKEN`。 +取值:`tgit`、`github`、`cnb`、`gitlab`、`gitcode`、`gitea`、`git`。`git` 不需要平台 token,用已有的 Git 凭据;PR/MR 需要自己到 Git 平台上创建。该选择只影响本机。自建 GitLab 用 `--provider gitlab` 时仍需设置 `GITLAB_URL` 和 `GITLAB_TOKEN`。 ## 通用 Git Provider(自建/私有仓库) @@ -236,7 +237,7 @@ GitCode 不设默认 email 域,使用用户的 git 全局配置。 ## 在 `teamai.yaml` 中指定 Provider -除了 URL 自动检测,也可以在 team 仓库的 `teamai.yaml` 中显式写 `provider: github`、`provider: tgit`、`provider: cnb`、`provider: gitlab`、`provider: gitcode` 或 `provider: git` 强制切换。一个典型的 `teamai.yaml`: +除了 URL 自动检测,也可以在 team 仓库的 `teamai.yaml` 中显式写 `provider: github`、`provider: tgit`、`provider: cnb`、`provider: gitlab`、`provider: gitcode`、`provider: gitea` 或 `provider: git` 强制切换。一个典型的 `teamai.yaml`: ```yaml team: my-team @@ -251,3 +252,71 @@ reviewers: ## 新增 Provider Provider 层的内部实现和新增 provider 的步骤见 [Adding a Git provider](../../dev/adding-a-provider.md)。 + + +## Gitea Provider(自托管) + +`gitea` provider 直接使用实例 REST API 和 Git,不需要额外的平台 CLI。接入前设置完整实例地址: + +```bash +export GITEA_URL=https://gitea.example.com +teamai init https://gitea.example.com/your-org/team.git --scope user +``` + +未知 HTTP(S) host 可通过匿名版本接口探测给出配置提示;探测不代表允许向该 host 发送 token。 +设置 `GITEA_URL` 后重试。本实现不把 gitea.com 全局映射为 Gitea。本地 Forgejo 15.0.10 已验证 init、push、PR 创建、reviewer 指派及实际 AI 后端的 PR 提取;main 另验证评审讨论读取,0.27 回移版不读取讨论。此实测子集不代表所有 Forgejo 版本或部署均已兼容。 + +### 认证和 URL + +设置 `GITEA_TOKEN`,或在 init 的隐藏终端提示中粘贴 PAT。程序先通过 `GET /api/v1/user` +验证,再以 `0600` 权限写入 `~/.netrc`。非交互运行必须已有 env/netrc token,缺失时立即失败; +已配置但无效的 token 会明确报错,不会反复启动登录。 + +PAT 需要覆盖仓库、PR/reviewer/评论、用户身份以及所使用的组织操作。不同服务器版本的 scope +可能不同,应依据目标实例 API 核对。管理员应在成员接入前创建组织和团队仓库;私有仓库的 +404 也可能表示无权访问,程序不会据此自动引导创建另一个仓库。 + +`GITEA_URL` 保留 HTTP(S) 协议、端口和反向代理子路径。HTTP 仓库/PR URL 必须匹配完整 +origin 和 base 路径。可选 `TEAMAI_GITEA_HOST` 必须是同一 hostname,填写 HTTP 端口时也须一致。 +SSH 可使用不同端口,但本期不支持不同 hostname 的 SSH 别名: + +```bash +export GITEA_URL=https://gitea.example.com/sub/path +teamai init ssh://git@gitea.example.com:2222/your-org/team.git --scope user +``` + +SSH init 仍需 PAT 验证平台身份,clone 及后续 Git 操作保留 SSH URL 并使用公钥。 +浅克隆导入的 `--ssh` 需提供带端口的显式 SSH URL,不能要求 CLI 从 HTTP URL 猜测 SSH 端口。 + +REST 使用 `Authorization: token `;HTTP Git 用已验证 login 和 token 做 Basic 认证。 +私有团队 clone 将凭据保存在 origin URL,供后续裸 `git push` 使用,因此应按含凭据目录保护。 +浅克隆资源使用一次性 header。修改 env/netrc 不会替换已有 origin 中的旧 token;轮换后需更新 +该 clone 的认证并分别验证 API 和 Git。迁移 HTTPS 也需更新保存的 remote,不能只改 `GITEA_URL`。 + +netrc 按 hostname 匹配,不分端口。同 host 的不同认证服务应隔离用户目录或显式管理凭据, +测试实例不能覆盖其他服务的 netrc 条目。 + +### 分享、导入与权限 + +`teamai push --all` 推分支、创建 PR,并按 Gitea login 请求 reviewers。已有 PR 按双方仓库及 +双方分支精确匹配,不能只看 head。reviewer 逐个请求:无效账号产生 warning,已创建的 PR 和 +其他有效邀请仍保留。 + +管理员先初始化 TeamAI 骨架和 reviewers 配置,再保护默认分支。普通成员需要工作分支及 +reports/learnings 分支写权限、PR 权限,不需要直接推送 main 的权限。 + +本 provider 支持 `teamai import --from-mr /org/repo/pulls/N` 的 PR 元信息、分页 +commits 和有限大小的 diff;超过 50 KiB 会标注证据不完整,必需证据读取失败会终止导入。 +默认读取评审正文、行内评论及普通 PR 评论;可关闭讨论读取。可选讨论接口失败会警告并保留其他已读取证据。本期不支持 Gitea 的 CI 提取/评论命令或 Actions 模板。 + +公开仓库可匿名浅克隆和 fetch,这不等于匿名 init、成员注册或 push。`teamai doctor` 检查 +实例 URL 配置和 token 是否存在;token 是否有效须通过真实 API 请求确认。 + +### 从 generic Git 迁移 + +所有成员先安装支持 Gitea 的版本,再将团队 manifest 改为 `provider: gitea`。混用旧版期间, +新版成员可用 `teamai init --provider gitea` 单独试用,团队继续保留 `provider: git`。 +成员本地显式选择优先于团队配置,曾设置 `--provider git` 的成员需重新初始化。团队配置修改 +经 PR 合并后,以普通成员能自动创建并更新 PR 作为验收。 + +参见 [English guide](../providers.md#gitea-provider-self-hosted)。 diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 63f4b2c2e..64d37ab77 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -23,3 +23,5 @@ Start with [Getting Started](guide/getting-started.md): set up a team, join one, - [Git Providers](guide/providers.md) — GitHub, GitLab, GitCode, CNB, TGit and generic Git: detection and authentication - [Uninstall & FAQ](guide/faq.md) — Removing TeamAI and frequently asked questions - [ClawPro HTTP backend](guide/http-contract.md) — APIs used by `init --http` + +[Self-hosted Gitea setup](guide/providers.md#gitea-provider-self-hosted): configure `GITEA_URL` and a PAT before initializing the team repository. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 7783e8dd4..1193c6ba6 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -23,3 +23,5 @@ - [Git Provider](guide/zh-CN/providers.md) — GitHub、GitLab、GitCode、CNB、工蜂与通用 Git:检测与认证 - [卸载与常见问题](guide/zh-CN/faq.md) — 卸载 TeamAI 与常见问题 - [ClawPro HTTP 后端](guide/zh-CN/http-contract.md) — `init --http` 使用的接口 + +[自托管 Gitea 配置](guide/zh-CN/providers.md):初始化前设置 `GITEA_URL` 和 PAT。 diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index f040d97f4..c7198e6bc 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -20,7 +20,7 @@ Generated: do not edit by hand. Regenerate with - `teamai init [repo]` — Initialize teamai (configure Git provider, clone repo, register member) - `--repo ` — Team repo (alias of the positional argument) - `--http ` — Git-free HTTP team repo (read-only consumer; only needs an API key) - - `--provider ` — Git provider for the team repo on this machine: tgit, github, cnb, gitlab, gitcode, or git. Skips auto-detection. `git` uses your existing Git auth and needs no platform token, but opens no PR/MR. + - `--provider ` — Git provider for the team repo on this machine: tgit, github, cnb, gitlab, gitcode, gitea, or git. Skips auto-detection. `git` uses your existing Git auth and needs no platform token, but opens no PR/MR. - `--self` — Single-repo mode: the current git repo is the team repo (equivalent to `teamai init .`). Knowledge lives on main under .teamai/; reports go to the teamai-reports orphan branch. - `--token ` — API key for HTTP team repo / status reporting (stored 0600, never committed). Also reads TEAMAI_API_TOKEN. - `--scope ` — Install scope: workspace (default, /.teamai + /.claude) or user (~/.teamai + ~/.claude); project is the older name for workspace diff --git a/skill-data/setup/references/setup-admin.md b/skill-data/setup/references/setup-admin.md index 99a3d3532..4910be077 100644 --- a/skill-data/setup/references/setup-admin.md +++ b/skill-data/setup/references/setup-admin.md @@ -28,12 +28,13 @@ through these sub-steps **in order**: on this machine — see `{SKILL_DIR}/references/provider-tgit.md` ("Probe reachability") for the one-line `x-env: tgit` check. If it says `tgit: OK`, **list Tencent TGit first** and prefer it. Then ask: *"Have you heard of / do you have an account on any of these — -Tencent TGit, GitHub, GitLab, or CNB (cnb.cool)?"* +Tencent TGit, GitHub, GitLab, Gitea (self-hosted), or CNB (cnb.cool)?"* - **Tencent TGit** — https://git.woa.com (Tencent-internal only; shown first when the probe above says `tgit: OK`) - **GitHub** — https://github.com - **GitLab** — https://gitlab.com (or a self-hosted company GitLab) +- **Gitea** — configure the full GITEA_URL; use a PAT via GITEA_TOKEN or the hidden init prompt. See docs/gitea-provider.md. Never guess an SSH port or treat a version probe as token authorization. - **CNB** — https://cnb.cool If they name one, use that platform and go to sub-step 2c. @@ -207,7 +208,7 @@ remaining numbers become additional roles; pass `--role ` for a non-interactive run when only a primary role is needed. Without a terminal (or with `CI` / `TEAMAI_NONINTERACTIVE` set) `init` never waits: a provider that would need a browser login fails at once and names the credential to prepare — -a token for GitHub / CNB / GitLab / GitCode, and for TGit a prior `gf auth +a token for GitHub / CNB / GitLab / GitCode / Gitea, and for TGit a prior `gf auth login` run in an interactive shell (see Step 3). **Which AI tools to set up — all of them by default (global rule 9).** Do not add diff --git a/src/__tests__/e2e/gitea-provider-live.test.ts b/src/__tests__/e2e/gitea-provider-live.test.ts new file mode 100644 index 000000000..680f598a5 --- /dev/null +++ b/src/__tests__/e2e/gitea-provider-live.test.ts @@ -0,0 +1,136 @@ +import '../helpers/isolate-home.js'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { GiteaProvider } from '../../providers/gitea/index.js'; +import { giteaJson, redactGiteaOutput } from '../../providers/gitea/gitea-api.js'; +import { giteaRepoPath, parseGiteaRepoInput } from '../../providers/gitea/repo-url.js'; +import { shallowClone, shallowFetch } from '../../clone.js'; + +/** + * Opt-in provider integration, using dedicated test accounts/repositories only. + * + * Required: TEAMAI_GITEA_LIVE=1, GITEA_URL, GITEA_TOKEN (author), + * TEAMAI_GITEA_TEST_REPO=org/teamai-test, TEAMAI_GITEA_TEST_REVIEWER, + * TEAMAI_GITEA_REVIEWER_TOKEN. Optional PUBLIC_URL / SSH_URL below enable + * anonymous/SSH cases. The write test merges a synthetic file into the fixture. + * This tests the provider API; it does not replace a real built-CLI run. + */ +const RUN = process.env.TEAMAI_GITEA_LIVE === '1' && !!process.env.GITEA_URL && !!process.env.GITEA_TOKEN + && !!process.env.TEAMAI_GITEA_TEST_REPO && !!process.env.TEAMAI_GITEA_TEST_REVIEWER && !!process.env.TEAMAI_GITEA_REVIEWER_TOKEN; +const publicUrl = process.env.TEAMAI_GITEA_PUBLIC_URL; +const sshUrl = process.env.TEAMAI_GITEA_SSH_URL; + +describe.skipIf(!RUN)('Gitea provider (dedicated live fixture)', () => { + const provider = new GiteaProvider(); + const runId = randomUUID().slice(0, 8); + const branch = `teamai-live-${runId}`; + let directory: string; + let clone: string; + let route: string; + let prNumber: number | undefined; + let pushed = false; + + function git(...args: string[]): string { + const result = spawnSync('git', args, { cwd: clone, encoding: 'utf8', timeout: 45_000, windowsHide: true }); + if (result.status !== 0) throw new Error(redactGiteaOutput(`Fixture git ${args[0]} failed: ${result.stderr ?? result.error?.message ?? ''}`)); + return result.stdout.trim(); + } + + beforeAll(async () => { + vi.stubEnv('GIT_TERMINAL_PROMPT', '0'); + vi.stubEnv('GIT_ASKPASS', 'echo'); + vi.stubEnv('GCM_INTERACTIVE', 'never'); + const repo = process.env.TEAMAI_GITEA_TEST_REPO!; + const info = parseGiteaRepoInput(repo); + if (!info.repo.startsWith('teamai-')) throw new Error('Use a dedicated teamai-* fixture repository.'); + route = giteaRepoPath(repo); + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'gitea-provider-live-')); + clone = path.join(directory, 'team'); + await provider.authenticate(); + provider.cloneRepo(info.httpsUrl, clone); + git('config', 'user.name', 'TeamAI integration fixture'); + git('config', 'user.email', 'teamai-fixture@example.test'); + }); + + afterAll(async () => { + // Only this run's feature ref is removed. The merged fixture file is kept + // as evidence, and no default branch, repository or account is deleted. + if (prNumber) { + try { + const current = await giteaJson<{ state: string }>(`${route}/pulls/${prNumber}`); + if (current.state === 'open') await giteaJson(`${route}/pulls/${prNumber}`, { method: 'PATCH', body: { state: 'closed' } }); + } catch { console.warn(`Fixture PR ${prNumber} needs manual cleanup.`); } + } + if (pushed) { + try { await giteaJson(`${route}/branches/${encodeURIComponent(branch)}`, { method: 'DELETE' }); } + catch { console.warn(`Fixture branch ${branch} needs manual cleanup.`); } + } + if (directory) fs.rmSync(directory, { recursive: true, force: true }); + vi.unstubAllEnvs(); + }); + + it('creates and recovers a reviewed PR, updates its branch, merges and reads evidence', { timeout: 120_000, retry: 0 }, async () => { + const metadata = await giteaJson<{ default_branch: string }>(route); + const reviewer = process.env.TEAMAI_GITEA_TEST_REVIEWER!; + const reviewerToken = process.env.TEAMAI_GITEA_REVIEWER_TOKEN!; + const filename = `gitea-provider-${runId}.txt`; + git('checkout', '-b', branch); + fs.writeFileSync(path.join(clone, filename), 'Synthetic provider integration evidence.\n'); + git('add', filename); + git('commit', '-m', `test: Gitea provider fixture ${runId}`); + git('push', 'origin', branch); + pushed = true; + const options = { repo: process.env.TEAMAI_GITEA_TEST_REPO!, source: branch, target: metadata.default_branch, + title: `Gitea provider integration ${runId}`, reviewers: [reviewer] }; + const url = await provider.createPullRequest(options); + prNumber = Number(url.match(/\/pulls\/(\d+)$/)?.[1]); + expect(prNumber).toBeGreaterThan(0); + expect(await provider.createPullRequest(options)).toBe(url); + const pull = await giteaJson<{ requested_reviewers: Array<{ login: string }> }>(`${route}/pulls/${prNumber}`); + expect(pull.requested_reviewers.map((item) => item.login)).toContain(reviewer); + fs.appendFileSync(path.join(clone, filename), 'Second revision follows the same branch.\n'); + git('add', filename); + git('commit', '-m', `test: update Gitea fixture ${runId}`); + git('push', 'origin', branch); + const updated = await giteaJson<{ head: { sha: string } }>(`${route}/pulls/${prNumber}`); + expect(updated.head.sha).toBe(git('rev-parse', 'HEAD')); + await giteaJson(`${route}/issues/${prNumber}/comments`, { method: 'POST', token: reviewerToken, + body: { body: `Conversation evidence ${runId}` } }); + await giteaJson(`${route}/pulls/${prNumber}/reviews`, { method: 'POST', token: reviewerToken, body: { + event: 'APPROVED', body: `Review decision ${runId}`, + comments: [{ path: filename, body: `Inline evidence ${runId}`, new_position: 1 }], + } }); + await giteaJson(`${route}/pulls/${prNumber}/merge`, { method: 'POST', token: reviewerToken, body: { do: 'merge' } }); + const data = await provider.fetchMergeRequest(url); + expect(data.title).toBe(options.title); + expect(data.mergedAt).toBeTruthy(); + expect(data.commits.length).toBeGreaterThanOrEqual(2); + expect(data.diff).toContain(filename); + const discussionBodies = data.discussions?.flatMap(thread => thread.notes.map(note => note.body)) ?? []; + expect(discussionBodies).toContain(`Conversation evidence ${runId}`); + expect(discussionBodies).toContain(`Review decision ${runId}`); + expect(discussionBodies).toContain(`Inline evidence ${runId}`); + }); + + it.skipIf(!publicUrl)('clones and incrementally fetches a public repo without credentials', async () => { + vi.stubEnv('GITEA_TOKEN', ''); + try { + const target = path.join(directory, 'public'); + const first = await shallowClone(publicUrl!, target, 'gitea', { forceAnonymous: true, timeoutMs: 15_000 }); + expect(first.cloneMethod).toBe('https-anonymous'); + expect((await shallowFetch(target, { provider: 'gitea', timeoutMs: 15_000 })).sha).toBe(first.sha); + } finally { vi.unstubAllEnvs(); } + }); + + it.skipIf(!sshUrl)('retains the explicit SSH transport and port', () => { + const target = path.join(directory, 'ssh'); + provider.cloneRepo(sshUrl!, target); + const result = spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: target, encoding: 'utf8', windowsHide: true }); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe(parseGiteaRepoInput(sshUrl!).httpsUrl); + }); +}); diff --git a/src/__tests__/gitea-provider.test.ts b/src/__tests__/gitea-provider.test.ts new file mode 100644 index 000000000..1be155c29 --- /dev/null +++ b/src/__tests__/gitea-provider.test.ts @@ -0,0 +1,456 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import * as homes from '../utils/home.js'; +import { getUserHome } from '../utils/home.js'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { GiteaProvider } from '../providers/gitea/index.js'; +import { assertGiteaHttpUrl, giteaBaseUrl, parseGiteaPrUrl, parseGiteaRepoInput } from '../providers/gitea/repo-url.js'; +import { getGiteaToken, giteaGetSync, giteaGitHeader, giteaJson, giteaList, giteaPullCreate, giteaRepoClone, giteaWhoami, readGiteaText, redactGiteaOutput, writeGiteaNetrcToken } from '../providers/gitea/gitea-api.js'; +import { giteaOrganizationExists } from '../providers/gitea/org.js'; +import { fetchGiteaMR } from '../providers/gitea/mr-fetch.js'; +import { probeSelfHostedGitea } from '../providers/gitea/probe.js'; +import { detectProvider, detectProviderForInit, getDefaultProvider, getProvider } from '../providers/registry.js'; +import { askSecret, isInteractive } from '../utils/prompt.js'; +import { log } from '../utils/logger.js'; + +vi.mock('node:child_process', async (original) => ({ ...await original(), spawnSync: vi.fn() })); +vi.mock('../utils/prompt.js', async (original) => ({ ...await original(), askSecret: vi.fn(), isInteractive: vi.fn(() => false) })); + +const instance = 'http://gitea.test:3000'; +const credentialsFile = path.join(getUserHome(), '.netrc'); +let fetchMock = vi.fn(); +let sequence = 0; + +function json(body: unknown, status = 200, headers?: Record): Response { + return new Response(status === 204 ? null : JSON.stringify(body), { status, headers }); +} + +function pull(base = 'main', repository = 'org/repo') { + return { number: 12, html_url: `${instance}/org/repo/pulls/12`, + head: { ref: 'feature', repo: { full_name: repository } }, base: { ref: base, repo: { full_name: repository } } }; +} + +const options = { repo: 'org/repo', source: 'feature', target: 'main', title: 'Share a skill' }; + +beforeEach(() => { + vi.stubEnv('GITEA_URL', instance); + vi.stubEnv('GITEA_TOKEN', `test-token-${++sequence}`); + vi.stubEnv('TEAMAI_GITEA_HOST', ''); + vi.stubEnv('GITLAB_URL', ''); + vi.stubEnv('TEAMAI_GITLAB_HOST', ''); + fs.rmSync(credentialsFile, { force: true }); + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + vi.mocked(spawnSync).mockReset(); + vi.mocked(isInteractive).mockReturnValue(false); + vi.spyOn(log, 'warn').mockImplementation(() => {}); + vi.spyOn(log, 'success').mockImplementation(() => {}); + vi.spyOn(log, 'info').mockImplementation(() => {}); +}); + +afterEach(() => { + fs.rmSync(credentialsFile, { force: true }); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('Gitea instance and URL boundary', () => { + it.each([ + ['org/repo', `${instance}/org/repo.git`], + [`${instance}/org/repo`, `${instance}/org/repo.git`], + [`${instance}/org/repo.git`, `${instance}/org/repo.git`], + [`${instance}/org/repo/pulls/12`, `${instance}/org/repo.git`], + ['ssh://git@gitea.test:2222/org/repo.git', 'ssh://git@gitea.test:2222/org/repo.git'], + ['git@gitea.test:org/repo.git', 'ssh://git@gitea.test/org/repo.git'], + ])('parses %s without changing the transport', (input, expected) => { + expect(parseGiteaRepoInput(input)).toEqual({ owner: 'org', repo: 'repo', projectId: 'org/repo', httpsUrl: expected }); + expect(detectProvider(input)).toBe(input === 'org/repo' ? getDefaultProvider() : 'gitea'); + }); + + it('strips only a complete configured reverse-proxy prefix', () => { + vi.stubEnv('GITEA_URL', 'https://gitea.test/sub/path/'); + expect(parseGiteaPrUrl('https://gitea.test/sub/path/org/repo/pulls/42').number).toBe(42); + expect(parseGiteaRepoInput('org/repo').httpsUrl).toBe('https://gitea.test/sub/path/org/repo.git'); + expect(() => parseGiteaRepoInput('https://gitea.test/sub/path-other/org/repo')).toThrow(/base path/); + }); + + it.each([ + 'http://gitea.test:3001/org/repo', 'https://gitea.test:3000/org/repo', + 'http://other.test:3000/org/repo', 'http://alice:secret@gitea.test:3000/org/repo', + 'ssh://git@alias.test:2222/org/repo', 'org/sub/repo', 'org/%2frepo', 'org/..', + 'org/repo/pull/12', 'org/repo/pulls/0', 'file:///org/repo', + ])('rejects mismatched or malformed URL %s before I/O', (input) => { + expect(() => parseGiteaRepoInput(input)).toThrow(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each(['', 'gitea.test', 'ftp://gitea.test', 'http://user:pass@gitea.test', 'http://gitea.test/?token=secret'])('rejects invalid GITEA_URL %s', (value) => { + vi.stubEnv('GITEA_URL', value); + expect(() => giteaBaseUrl()).toThrow(/GITEA_URL/); + }); + + it('allows the same host hint but rejects a different hostname or HTTP port', () => { + vi.stubEnv('TEAMAI_GITEA_HOST', 'gitea.test'); + expect(giteaBaseUrl()).toBe(instance); + vi.stubEnv('TEAMAI_GITEA_HOST', 'gitea.test:3001'); + expect(() => giteaBaseUrl()).toThrow(/same instance/); + expect(() => assertGiteaHttpUrl('http://other.test/org/repo')).toThrow(); + }); + + it('registers the provider and explicit default override', () => { + vi.stubEnv('TEAMAI_DEFAULT_PROVIDER', 'gitea'); + expect(getDefaultProvider()).toBe('gitea'); + expect(getProvider('gitea')).toBeInstanceOf(GiteaProvider); + expect(new GiteaProvider().getDefaultEmailDomain()).toBeNull(); + expect(new GiteaProvider().getOrganizationCreateUrl()).toBe(`${instance}/org/create`); + }); +}); + +describe('Gitea credentials', () => { + it('prefers env over a multiline host-scoped netrc entry', () => { + fs.writeFileSync(credentialsFile, 'machine gitea.test\n login alice\n password saved-token\nmachine other.test login bob password other-token\n'); + expect(getGiteaToken()).toBe(process.env.GITEA_TOKEN); + vi.stubEnv('GITEA_TOKEN', ''); + expect(getGiteaToken()).toBe('saved-token'); + }); + + it('preserves other hosts when replacing credentials and enforces mode 0600', () => { + fs.writeFileSync(credentialsFile, 'machine other.test login bob password keep-me\nmachine gitea.test\n login old\n password old-token\n', { mode: 0o644 }); + writeGiteaNetrcToken('new-token', 'alice'); + const text = fs.readFileSync(credentialsFile, 'utf8'); + expect(text).toContain('password keep-me'); + expect(text).not.toContain('old-token'); + expect(text).toContain('machine gitea.test login alice password new-token'); + if (process.platform !== 'win32') expect(fs.statSync(credentialsFile).mode & 0o777).toBe(0o600); + }); + + it('does not prompt without a TTY, but accepts a valid configured token', async () => { + vi.stubEnv('GITEA_TOKEN', ''); + await expect(new GiteaProvider().authenticate()).rejects.toThrow(/GITEA_TOKEN/); + expect(askSecret).not.toHaveBeenCalled(); + vi.stubEnv('GITEA_TOKEN', 'noninteractive-token'); + fetchMock.mockResolvedValue(json({ login: 'alice' })); + await expect(new GiteaProvider().authenticate()).resolves.toBe('alice'); + }); + + it('verifies a pasted PAT before saving it', async () => { + vi.stubEnv('GITEA_TOKEN', ''); + vi.mocked(isInteractive).mockReturnValue(true); + vi.mocked(askSecret).mockResolvedValue('pasted-token'); + fetchMock.mockResolvedValue(json({ login: 'alice' })); + await expect(new GiteaProvider().authenticate()).resolves.toBe('alice'); + expect(getGiteaToken()).toBe('pasted-token'); + expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ Authorization: 'token pasted-token' }); + }); + + it.each([401, 403])('does not save rejected interactive credentials (%s)', async (status) => { + vi.stubEnv('GITEA_TOKEN', ''); + vi.mocked(isInteractive).mockReturnValue(true); + vi.mocked(askSecret).mockResolvedValue('rejected-token'); + fetchMock.mockResolvedValue(json({ message: 'private server detail' }, status)); + await expect(new GiteaProvider().authenticate()).rejects.toThrow(status === 401 ? /token rejected/ : /permission denied/); + expect(fs.existsSync(credentialsFile)).toBe(false); + }); + + it('caches login per instance and token, and builds Basic with the real login', async () => { + fetchMock.mockResolvedValue(json({ login: 'alice' })); + expect(await giteaWhoami()).toBe('alice'); + expect(await giteaGitHeader()).toBe(`Authorization: Basic ${Buffer.from(`alice:${process.env.GITEA_TOKEN}`).toString('base64')}`); + expect(fetchMock).toHaveBeenCalledTimes(1); + vi.stubEnv('GITEA_TOKEN', 'another-token'); + fetchMock.mockResolvedValue(json({ login: 'bob' })); + expect(await giteaWhoami()).toBe('bob'); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('redacts raw, encoded and header-form credentials', () => { + vi.stubEnv('GITEA_TOKEN', 'secret:/token@'); + const text = redactGiteaOutput('secret:/token@ secret%3A%2Ftoken%40 http://alice:secret%3A%2Ftoken%40@gitea.test Authorization: Basic YWxpY2U6c2VjcmV0'); + expect(text).not.toContain('secret'); + expect(text).not.toContain('YWxpY2U'); + }); +}); + +describe('Gitea REST and pagination', () => { + it('reads short pages until empty, even if the server clamps page size', async () => { + fetchMock.mockResolvedValueOnce(json([{ id: 1 }])).mockResolvedValueOnce(json([{ id: 2 }])).mockResolvedValueOnce(json([])); + expect(await giteaList('/orgs/org/repos')).toEqual([{ id: 1 }, { id: 2 }]); + expect(String(fetchMock.mock.calls[1][0])).toContain('page=2'); + }); + + it('follows valid next-page links without changing endpoints', async () => { + fetchMock.mockResolvedValueOnce(json([1], 200, { Link: `<${instance}/api/v1/orgs/org/repos?page=3>; rel="next"` })) + .mockResolvedValueOnce(json([2], 200, { Link: `<${instance}/api/v1/orgs/org/repos?page=1>; rel="first"` })); + expect(await giteaList('/orgs/org/repos')).toEqual([1, 2]); + expect(String(fetchMock.mock.calls[1][0])).toContain('page=3'); + }); + + it('rejects cross-host pagination without sending a second token', async () => { + fetchMock.mockResolvedValueOnce(json([1], 200, { Link: '; rel="next"' })); + await expect(giteaList('/orgs/org/repos')).rejects.toThrow(/next-page/); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('caps org lists explicitly and supports maxRepos=0', async () => { + expect(await giteaList('/orgs/org/repos', 0)).toEqual([]); + expect(fetchMock).not.toHaveBeenCalled(); + fetchMock.mockResolvedValueOnce(json([1, 2, 3])); + expect(await giteaList('/orgs/org/repos', 2)).toEqual([1, 2]); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('maxRepos=2')); + await expect(giteaList('/orgs/org/repos', -1)).rejects.toThrow(/maxRepos/); + }); + + it('rejects redirects, invalid JSON, malformed lists and oversize JSON', async () => { + fetchMock.mockResolvedValueOnce(json({}, 302)); + await expect(giteaJson('/user')).rejects.toThrow(/302/); + fetchMock.mockResolvedValueOnce(new Response('not json')); + await expect(giteaJson('/user')).rejects.toThrow(/invalid JSON/); + fetchMock.mockResolvedValueOnce(json({ unexpected: true })); + await expect(giteaList('/orgs/org/repos')).rejects.toThrow(/invalid list/); + await expect(readGiteaText(new Response('12345'), 4)).rejects.toThrow(/size limit/); + }); + + it('cancels a stalled API request within its timeout', async () => { + vi.useFakeTimers(); + fetchMock.mockImplementation((_url, init) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new Error('request aborted'))); + })); + const result = expect(giteaJson('/user')).rejects.toThrow(/aborted/); + await vi.advanceTimersByTimeAsync(15_000); + await result; + }); +}); + +describe('Gitea PR create and recovery', () => { + it('creates a PR and independently requests valid and invalid reviewers', async () => { + fetchMock.mockResolvedValueOnce(json(pull(), 201)).mockResolvedValueOnce(json({}, 201)).mockResolvedValueOnce(json({}, 422)); + expect(await new GiteaProvider().createPullRequest({ ...options, reviewers: ['alice', 'missing'] })).toBe(pull().html_url); + expect(JSON.parse(String(fetchMock.mock.calls[1][1]?.body))).toEqual({ reviewers: ['alice'] }); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('missing')); + }); + + it('recovers the complete identity across pages and still requests reviewers', async () => { + fetchMock.mockResolvedValueOnce(json({}, 409)).mockResolvedValueOnce(json([pull('release'), pull('main', 'fork/repo')])) + .mockResolvedValueOnce(json([pull()])).mockResolvedValueOnce(json([])).mockResolvedValueOnce(json({}, 201)); + expect(await giteaPullCreate({ ...options, reviewers: ['alice'] })).toBe(pull().html_url); + expect(String(fetchMock.mock.calls.at(-1)?.[0])).toContain('/pulls/12/requested_reviewers'); + }); + + it('finds an already-created PR after an ambiguous transport failure', async () => { + fetchMock.mockRejectedValueOnce(new TypeError('network reset')).mockResolvedValueOnce(json([pull()])).mockResolvedValueOnce(json([])); + expect(await giteaPullCreate(options)).toBe(pull().html_url); + }); + + it('retains a 409 when no exact PR exists', async () => { + fetchMock.mockResolvedValueOnce(json({}, 409)).mockResolvedValueOnce(json([pull('release')])).mockResolvedValueOnce(json([])); + await expect(giteaPullCreate(options)).rejects.toThrow(/409/); + }); + + it('does not retry a definitive authorization or server failure', async () => { + fetchMock.mockResolvedValueOnce(json({}, 403)); + await expect(giteaPullCreate(options)).rejects.toThrow(/permission/); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('rejects a PR response from another instance', async () => { + fetchMock.mockResolvedValueOnce(json({ ...pull(), html_url: 'http://other.test/org/repo/pulls/12' }, 201)); + await expect(giteaPullCreate(options)).rejects.toThrow(/configured instance/); + }); +}); + +describe('Gitea clone and organizations', () => { + it('preserves an explicit SSH origin and port without attaching a PAT', () => { + vi.mocked(spawnSync).mockReturnValue({ status: 0, stdout: '', stderr: '' } as never); + new GiteaProvider().cloneRepo('ssh://git@gitea.test:2222/org/repo.git', '/test/clone'); + expect(spawnSync).toHaveBeenCalledWith('git', ['clone', 'ssh://git@gitea.test:2222/org/repo.git', '/test/clone'], expect.anything()); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('persists encoded HTTP credentials after verification for subsequent pushes', async () => { + vi.stubEnv('GITEA_TOKEN', 'token:/@'); + fetchMock.mockResolvedValueOnce(json({ login: 'alice' })); + await giteaWhoami(); + vi.mocked(spawnSync).mockReturnValue({ status: 0, stdout: '', stderr: '' } as never); + giteaRepoClone('org/repo', '/test/clone'); + expect(spawnSync).toHaveBeenCalledWith('git', ['clone', 'http://alice:token%3A%2F%40@gitea.test:3000/org/repo.git', '/test/clone'], expect.anything()); + }); + + it('uses Node stdin for synchronous API lookups, keeping tokens out of argv', () => { + vi.mocked(spawnSync).mockReturnValue({ status: 0, stdout: JSON.stringify({ status: 200, text: '{"id":1}' }), stderr: '' } as never); + expect(giteaGetSync('/orgs/org')).toEqual({ id: 1 }); + expect(JSON.stringify(vi.mocked(spawnSync).mock.calls[0][1])).not.toContain(process.env.GITEA_TOKEN); + expect(giteaOrganizationExists('org')).toBe(true); + }); + + it('distinguishes missing organizations from denied or timed-out lookups', () => { + vi.mocked(spawnSync).mockReturnValueOnce({ status: 0, stdout: JSON.stringify({ status: 404, text: '{}' }) } as never); + expect(giteaOrganizationExists('org')).toBe(false); + vi.mocked(spawnSync).mockReturnValueOnce({ status: 0, stdout: JSON.stringify({ status: 403, text: '{}' }) } as never); + expect(() => giteaOrganizationExists('org')).toThrow(/permission denied/); + vi.mocked(spawnSync).mockReturnValueOnce({ status: null, stdout: '' } as never); + expect(() => giteaGetSync('/user')).toThrow(/timed out/); + }); + + it('maps organization repository metadata and validates supplied clone URLs', async () => { + fetchMock.mockResolvedValueOnce(json([{ name: 'repo', full_name: 'org/repo', clone_url: `${instance}/org/repo.git`, language: 'TypeScript', stars_count: 2 }])) + .mockResolvedValueOnce(json([])); + expect(await new GiteaProvider().listOrgRepos('org')).toEqual([expect.objectContaining({ name: 'repo', fullName: 'org/repo', primaryLanguage: 'TypeScript', stars: 2 })]); + }); + + it.each([['alice', '/user/repos'], ['org', '/orgs/org/repos']])('creates a private empty repo for %s', async (owner, endpoint) => { + fetchMock.mockResolvedValueOnce(json({ login: 'alice' })).mockResolvedValueOnce(json({}, 201)); + await new GiteaProvider().createRepo(owner, 'repo'); + expect(String(fetchMock.mock.calls[1][0])).toBe(`${instance}/api/v1${endpoint}`); + expect(JSON.parse(String(fetchMock.mock.calls[1][1]?.body))).toEqual({ name: 'repo', private: true, auto_init: false }); + }); +}); + +describe('Gitea PR evidence', () => { + it('fetches paginated commits and marks bounded diff evidence explicitly', async () => { + fetchMock.mockResolvedValueOnce(json({ title: 'Fix', body: 'Reason', user: { login: 'alice' }, merged_at: '2026-10-18T00:00:00Z' })) + .mockResolvedValueOnce(json([{ sha: 'abc', commit: { message: 'Fix input\n\nDetails' } }])) + .mockResolvedValueOnce(json([{ sha: 'def', commit: { message: 'Add regression' } }])) + .mockResolvedValueOnce(json([])).mockResolvedValueOnce(new Response('你'.repeat(20000))); + const data = await new GiteaProvider().fetchMergeRequest(`${instance}/org/repo/pulls/12`, { discussions: false }); + expect(data.commits).toHaveLength(2); + expect(data.author).toBe('alice'); + expect(data.diff).toContain('Diff truncated'); + expect(data.diff).not.toContain('\ufffd'); + expect(Buffer.byteLength(data.diff)).toBeLessThanOrEqual(50 * 1024); + }); + + it('does not silently import missing required diff data', async () => { + fetchMock.mockResolvedValueOnce(json({ title: 'Fix' })).mockResolvedValueOnce(json([])).mockResolvedValueOnce(json({}, 403)); + await expect(fetchGiteaMR(`${instance}/org/repo/pulls/12`)).rejects.toThrow(/permission/); + }); +}); + +describe('anonymous self-hosted Gitea probe', () => { + it('detects a subpath candidate without Authorization or product-name guesses', async () => { + fetchMock.mockResolvedValueOnce(json({ version: '15.0.0' })); + expect(await probeSelfHostedGitea('http://gitea.test:3000/sub/path/org/repo')).toEqual({ baseUrl: `${instance}/sub/path`, version: '15.0.0' }); + expect(fetchMock.mock.calls[0][1]?.headers).toEqual({ Accept: 'application/json' }); + }); + + it('instructs init to configure a compatible but unknown host', async () => { + vi.stubEnv('GITEA_URL', ''); + fetchMock.mockResolvedValueOnce(json({}, 404)).mockResolvedValueOnce(json({ version: '1.27.3' })); + await expect(detectProviderForInit(`${instance}/org/repo`)).rejects.toThrow(/Set GITEA_URL/); + }); + + it('does not probe SSH or credential-bearing URLs', async () => { + expect(await probeSelfHostedGitea('ssh://git@gitea.test:2222/org/repo')).toBeNull(); + expect(await probeSelfHostedGitea('http://user:pass@gitea.test/org/repo')).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('bounds the whole probe even when fetch ignores cancellation', async () => { + vi.useFakeTimers(); + fetchMock.mockImplementation(() => new Promise(() => {})); + const pending = probeSelfHostedGitea(`${instance}/sub/path/org/repo`, 20); + await vi.advanceTimersByTimeAsync(20); + expect(await pending).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); + + +describe('Gitea review discussions', () => { + function evidence(failReview = false) { + fetchMock.mockImplementation(async (input) => { + const url = new URL(String(input)); + const route = url.pathname; + if (route.endsWith('/pulls/12')) return json({ title: 'Fix' }); + if (route.endsWith('.diff')) return new Response('diff'); + if (!route.endsWith('/comments') && url.searchParams.get('page') !== '1') return json([]); + if (route.endsWith('/commits')) return json([]); + if (route.endsWith('/issues/12/comments')) return json([ + { id: 3, body: 'general', created_at: '2026-01-03', user: { login: 'author' } }, + { id: 3, body: 'general', created_at: '2026-01-03', user: { login: 'author' } }, + { id: 4, body: ' generated' }, + { id: 5, body: 'CUSTOM generated' }, + ]); + if (route.endsWith('/reviews')) return json([ + { id: 10, body: 'review summary', state: 'APPROVED', submitted_at: '2026-01-01', user: { login: 'reviewer' } }, + { id: 11, body: 'draft secret', state: 'PENDING' }, + { id: 12, body: 'invitation', state: 'REQUEST_REVIEW' }, + ]); + if (route.endsWith('/reviews/10/comments')) return failReview ? json({}, 403) : json([ + { id: 20, body: 'inline one', path: 'a.ts', position: 5, resolver: null, created_at: '2026-01-02' }, + { id: 21, body: 'inline two', path: 'a.ts', position: 5, resolver: { login: 'author' }, created_at: '2026-01-02' }, + { id: 22, body: ' ' }, + ]); + throw new Error('Unexpected API route: ' + route); + }); + } + it('preserves review, inline and issue evidence without inventing reply links or line numbers', async () => { + evidence(); + const data = await fetchGiteaMR(`${instance}/org/repo/pulls/12`, { commentMarker: 'CUSTOM' }); + expect(data.discussions).toHaveLength(4); + expect(data.discussions?.map(t => t.notes[0].body)).toEqual(['review summary', 'inline one', 'inline two', 'general']); + expect(data.discussions?.[1]).toMatchObject({ file: 'a.ts', resolved: false, sourceId: 'review:10:comment:20', createdAt: '2026-01-02' }); + expect(data.discussions?.[1].line).toBeUndefined(); + expect(data.discussions?.[2].resolved).toBe(true); + expect(data.discussions?.every(t => t.notes.length === 1)).toBe(true); + for (const [input] of fetchMock.mock.calls) { + const url = new URL(String(input)); + if (url.pathname.endsWith('/comments')) expect(url.search).toBe(''); + } + }); + it('does not request discussions when disabled', async () => { + evidence(); + const data = await new GiteaProvider().fetchMergeRequest(`${instance}/org/repo/pulls/12`, { discussions: false }); + expect(data.discussions).toBeUndefined(); + expect(fetchMock.mock.calls.some(([url]) => /reviews|comments/.test(String(url)))).toBe(false); + }); + it('keeps required evidence and successful discussions when an inline endpoint fails', async () => { + evidence(true); + const data = await fetchGiteaMR(`${instance}/org/repo/pulls/12`, { commentMarker: 'CUSTOM' }); + expect(data.diff).toBe('diff'); + expect(data.discussions?.map(t => t.notes[0].body)).toEqual(['review summary', 'general']); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('review 10 comments unavailable')); + }); +}); + + +describe('Gitea credential home compatibility', () => { + it('reads a legacy netrc only after the current home and writes to the current home', () => { + vi.stubEnv('GITEA_TOKEN', ''); + const legacy = fs.mkdtempSync(path.join(getUserHome(), 'gitea-legacy-')); + try { + vi.spyOn(homes, 'legacyUserHome').mockReturnValue(legacy); + fs.writeFileSync(path.join(legacy, '.netrc'), 'machine gitea.test login old password legacy-token\n'); + expect(getGiteaToken()).toBe('legacy-token'); + writeGiteaNetrcToken('current-token', 'alice'); + expect(getGiteaToken()).toBe('current-token'); + expect(fs.readFileSync(path.join(legacy, '.netrc'), 'utf8')).toContain('legacy-token'); + expect(fs.readFileSync(credentialsFile, 'utf8')).toContain('current-token'); + } finally { fs.rmSync(legacy, { recursive: true, force: true }); } + }); +}); + + +describe('Gitea total-count pagination', () => { + it('stops when a non-paginated comment list reports its complete total', async () => { + fetchMock.mockResolvedValue(json([{ id: 5, body: 'comment' }], 200, { 'x-total-count': '1' })); + expect(await giteaList('/repos/org/repo/issues/1/comments')).toHaveLength(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it('continues across a server-clamped page until the declared total is reached', async () => { + fetchMock.mockResolvedValueOnce(json([{ id: 1 }], 200, { 'x-total-count': '2' })) + .mockResolvedValueOnce(json([{ id: 2 }], 200, { 'x-total-count': '2' })); + expect(await giteaList('/repos/org/repo/pulls')).toEqual([{ id: 1 }, { id: 2 }]); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + + +it('honors an explicit next link before a conflicting total count', async () => { + fetchMock.mockResolvedValueOnce(json([{ id: 1 }], 200, { 'x-total-count': '1', link: `<${instance}/api/v1/repos/org/repo/pulls?page=2>; rel="next"` })) + .mockResolvedValueOnce(json([])); + expect(await giteaList('/repos/org/repo/pulls')).toEqual([{ id: 1 }]); + expect(fetchMock).toHaveBeenCalledTimes(2); +}); diff --git a/src/__tests__/gitlab-probe.test.ts b/src/__tests__/gitlab-probe.test.ts index 0129f86b0..574d43eb1 100644 --- a/src/__tests__/gitlab-probe.test.ts +++ b/src/__tests__/gitlab-probe.test.ts @@ -260,7 +260,8 @@ describe('provider discovery during initialization', () => { fetchMock.mockResolvedValue(htmlResponse('

Company Git hosting

')); await expect(detectProviderForInit(repoUrl)).resolves.toBe('git'); - expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(String(fetchMock.mock.calls[1][0])).toContain('/api/v1/version'); }); it('reports the detected instance and required setup before writing a provider choice', async () => { diff --git a/src/__tests__/import-mr-learnings-checkout.test.ts b/src/__tests__/import-mr-learnings-checkout.test.ts index f3d43546a..2e5d9c53f 100644 --- a/src/__tests__/import-mr-learnings-checkout.test.ts +++ b/src/__tests__/import-mr-learnings-checkout.test.ts @@ -33,7 +33,8 @@ vi.mock('../config.js', async (importOriginal) => ({ const IMPORTED = 'mr-note-2026-01-01-aaaaaa.md'; -vi.mock('../import-mr.js', () => ({ +vi.mock('../import-mr.js', async (importOriginal) => ({ + ...(await importOriginal()), importFromMR: vi.fn(async (opts: { queueLearning?: (filename: string, content: string) => Promise }) => { const content = '# Learning imported from an MR\n'; const learningFile = opts.queueLearning ? await opts.queueLearning(IMPORTED, content) : undefined; diff --git a/src/__tests__/import-mr-threads.test.ts b/src/__tests__/import-mr-threads.test.ts index a300187b1..d6e84d177 100644 --- a/src/__tests__/import-mr-threads.test.ts +++ b/src/__tests__/import-mr-threads.test.ts @@ -66,7 +66,7 @@ describe('formatMrThreads', () => { it('labels an unresolved thread and one without a resolution state', () => { const text = formatMrThreads([{ ...replied, resolved: false }, { ...replied, resolved: undefined, file: undefined }], 'alice'); expect(text).toContain('src/up.ts:42 (unresolved)'); - expect(text).toMatch(/^### Thread 2\n/m); + expect(text).toMatch(/^### Thread 2 \(resolution unknown\)\n/m); }); it('shortens a long thread to its first and last notes instead of cutting the section', () => { @@ -98,11 +98,11 @@ describe('extractMRLearningPrompt', () => { expect(prompt).toContain('用中文撰写'); }); - it('adds the threads with the instruction that a resolved decision outweighs the diff', () => { + it('adds threads while requiring evidence for resolved decisions', () => { const prompt = extractMRLearningPrompt({ ...mr, discussions: [remark, replied] }); expect(prompt).toContain('Review threads'); expect(prompt).toContain('[author alice]'); - expect(prompt).toMatch(/resolved 的 thread 里的决定比复述 diff 更重要/); + expect(prompt).toContain('only when its notes explicitly establish that decision'); // The threads do not push the diff out. expect(prompt).toContain('+retry(3)'); expect(extractMRLearningPrompt({ ...mr, discussions: [replied] }, 'en')) @@ -125,3 +125,12 @@ describe('extractMRLearningPrompt', () => { } }); }); + + +it.each(['en', 'zh', undefined] as const)('keeps an unknown resolution explicit in the %s extraction prompt', (language) => { + const prompt = extractMRLearningPrompt({ ...mr, discussions: [{ notes: [{ author: 'bob', body: 'Evidence label only' }] }] }, language); + expect(prompt).toContain('(resolution unknown)'); + expect(prompt).toContain('do not infer resolved from a missing label or from the MR being merged'); + expect(prompt).toContain('Do not invent replies, agreements or resolution events'); + expect(prompt).not.toContain('(resolved)'); +}); diff --git a/src/__tests__/import-mr.test.ts b/src/__tests__/import-mr.test.ts index ffed3cae6..ccb4ca317 100644 --- a/src/__tests__/import-mr.test.ts +++ b/src/__tests__/import-mr.test.ts @@ -37,6 +37,15 @@ vi.mock('../config.js', async (importOriginal) => ({ vi.mock('../providers/github/mr-fetch.js', () => ({ fetchGitHubPR: vi.fn(async (url: string) => ({ title: 'Retry budget', description: '', commits: [], diff: '', url })), })); +vi.mock('../providers/gitea/mr-fetch.js', () => ({ + fetchGiteaMR: vi.fn(async (url: string) => ({ title: 'Gitea route', description: '', commits: [], diff: '', url })), +})); +vi.mock('../providers/gitlab/mr-fetch.js', () => ({ + fetchGitLabMR: vi.fn(async (url: string) => ({ title: 'GitLab route', description: '', commits: [], diff: '', url })), +})); +vi.mock('../providers/tgit/mr-fetch.js', () => ({ + fetchTGitMR: vi.fn(async (url: string) => ({ title: 'TGit route', description: '', commits: [], diff: '', url })), +})); vi.mock('../utils/ai-client.js', () => ({ callClaude: vi.fn(async () => `---\ntitle: Retry budget\n---\n${DRAFT_BODY}\n`), })); @@ -50,6 +59,7 @@ import { importCmd } from '../import.js'; import { log, setSilent } from '../utils/logger.js'; import { callClaude } from '../utils/ai-client.js'; import { fetchGitHubPR } from '../providers/github/mr-fetch.js'; +import { fetchGiteaMR } from '../providers/gitea/mr-fetch.js'; describe('parseLearningDraft', () => { it('parses well-formed frontmatter', () => { @@ -277,6 +287,20 @@ describe('teamai import --from-mr (#1104)', () => { expect(vi.mocked(callClaude).mock.calls[0][0]).toContain('Write in English'); }); + it('keeps rejected credentials out of batch progress and failure summaries', async () => { + const rejected = 'http://sentinel-user:sentinel-password@gitea.test:3000/org/repo/pulls/1?token=sentinel-query#sentinel-fragment'; + const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + try { + await importCmd({ fromMr: [rejected, urls[0]!], dryRun: true, all: true }); + } finally { + write.mockRestore(); + } + const messages = [...infos(), ...warnings(), ...vi.mocked(log.error).mock.calls.map(([message]) => String(message))].join('\n'); + expect(messages).toContain('http://gitea.test:3000/org/repo/pulls/1'); + expect(messages).not.toMatch(/sentinel-/); + expect(process.exitCode).toBe(1); + }); + it('keeps going when one MR fails, reports it and exits non-zero', async () => { vi.mocked(fetchGitHubPR).mockRejectedValueOnce(new Error('MR not found')); const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); @@ -334,3 +358,43 @@ describe('teamai import --from-mr (#1104)', () => { expect(fs.readdirSync(out)).toEqual(['learning.md']); }); }); + + +describe('configured Gitea MR routing', () => { + afterEach(() => vi.unstubAllEnvs()); + it.each([ + { base: 'http://gitea.test:3000', repo: 'org/github.com' }, + { base: 'http://git.woa.com.gitea.test:3000', repo: 'org/repo' }, + { base: 'http://gitea.test:3000/proxy/-/merge_requests/23', repo: 'org/repo' }, + ])('uses the validated Gitea instance before provider hints in $base/$repo', async ({ base, repo }) => { + vi.stubEnv('GITEA_URL', base); + vi.mocked(fetchGiteaMR).mockClear(); + const url = `${base}/${repo}/pulls/12`; + const result = await importFromMR({ url, learningsDirs: [], all: true, dryRun: true }); + expect(fetchGiteaMR).toHaveBeenCalled(); + expect(vi.mocked(fetchGiteaMR).mock.calls[0][0]).toBe(url); + expect(result.repoUrl).toBe(`${base}/${repo}.git`); + }); +}); + + +describe('rejected MR URL diagnostics', () => { + it('sanitizes a rejected URL echoed by a legacy provider parser', async () => { + const url = 'http://sentinel-user:sentinel-password@gitea.test:3000/org/github.com/pulls/1?token=sentinel-query#sentinel-fragment'; + vi.mocked(fetchGitHubPR).mockRejectedValueOnce(new Error(`Invalid GitHub PR URL: ${url}`)); + const error = await importFromMR({ url, learningsDirs: [], dryRun: true }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('http://gitea.test:3000/org/github.com/pulls/1'); + expect((error as Error).message).not.toMatch(/sentinel-/); + }); + + it.each([ + ['http://sentinel-user:sentinel-password@gitea.test:3000/org/repo/pulls/1?token=sentinel-query#sentinel-fragment', 'http://gitea.test:3000/org/repo/pulls/1'], + ['sentinel-password invalid URL', '[invalid MR URL]'], + ])('omits credentials and parameters from a rejected URL error: %s', async (url, expected) => { + const error = await importFromMR({ url, learningsDirs: [], dryRun: true }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain(expected); + expect((error as Error).message).not.toMatch(/sentinel-/); + }); +}); diff --git a/src/__tests__/init.test.ts b/src/__tests__/init.test.ts index 7105cd02a..10adf4015 100644 --- a/src/__tests__/init.test.ts +++ b/src/__tests__/init.test.ts @@ -1430,7 +1430,7 @@ describe('init --provider', () => { }); it('accepts every provider name the team config accepts', () => { - for (const name of ['tgit', 'github', 'cnb', 'gitlab', 'gitcode', 'git']) { + for (const name of ['tgit', 'github', 'cnb', 'gitlab', 'gitcode', 'gitea', 'git']) { expect(resolveInitProvider(name)).toBe(name); } expect(resolveInitProvider(undefined)).toBeUndefined(); @@ -1438,7 +1438,7 @@ describe('init --provider', () => { it('rejects an unknown provider and names the valid ones', () => { expect(() => resolveInitProvider('GitLab')).toThrow( - 'Invalid --provider "GitLab". Use one of: tgit, github, cnb, gitlab, gitcode, git, ' + 'Invalid --provider "GitLab". Use one of: tgit, github, cnb, gitlab, gitcode, gitea, git, ' + 'or omit --provider to detect it from the repo URL.', ); }); diff --git a/src/__tests__/provider-fallback.test.ts b/src/__tests__/provider-fallback.test.ts index c60802a19..084182662 100644 --- a/src/__tests__/provider-fallback.test.ts +++ b/src/__tests__/provider-fallback.test.ts @@ -80,6 +80,11 @@ describe('getDefaultProvider (fallback)', () => { expect(getDefaultProvider()).toBe('github'); }); + it('accepts Gitea as the explicit default provider', () => { + process.env.TEAMAI_DEFAULT_PROVIDER = 'gitea'; + expect(getDefaultProvider()).toBe('gitea'); + }); + it('ignores unknown TEAMAI_DEFAULT_PROVIDER values', () => { setPackageName('teamai-cli'); process.env.TEAMAI_DEFAULT_PROVIDER = 'bitbucket'; diff --git a/src/clone.ts b/src/clone.ts index 22efe7062..8e0f92fcd 100644 --- a/src/clone.ts +++ b/src/clone.ts @@ -5,6 +5,8 @@ import fs from 'fs-extra'; import { getGitHubToken } from './providers/github/gh-cli.js'; import { getGitLabToken } from './providers/gitlab/gitlab-api.js'; import { getGitCodeToken } from './providers/gitcode/gitcode-api.js'; +import { giteaGitHeader, redactGiteaOutput } from './providers/gitea/gitea-api.js'; +import { parseGiteaRepoInput } from './providers/gitea/repo-url.js'; import { tgitGitCloneUrl } from './providers/tgit/rest-auth.js'; import { log } from './utils/logger.js'; import { sanitizeGitUrl } from './utils/redact.js'; @@ -65,7 +67,7 @@ export { sanitizeGitUrl }; * 对日志/错误信息中的 token 进行脱敏。 */ function redactToken(msg: string): string { - return sanitizeGitUrl(msg); + return redactGiteaOutput(msg); } /** @@ -103,7 +105,7 @@ function runCommand( const timer = setTimeout(() => { child.kill(); - reject(new Error(`Command timed out after ${opts.timeoutMs}ms: ${cmd} ${args.join(' ')}`)); + reject(new Error(redactToken(`Command timed out after ${opts.timeoutMs}ms: ${cmd} ${args.join(' ')}`))); }, opts.timeoutMs); child.on('close', (code) => { @@ -138,17 +140,26 @@ async function gitCmd( * the URL as given for Git's own credential helper. Shared with * {@link remoteHeadSha}, so a dry run asks the remote the way the clone would. */ -function resolveCloneAuth( +async function resolveCloneAuth( url: string, provider: string, opts: { forceSsh: boolean; forceAnonymous: boolean }, -): { cloneUrl: string; cloneMethod: CloneResult['cloneMethod']; extraAuthHeader?: string } { +): Promise<{ cloneUrl: string; cloneMethod: CloneResult['cloneMethod']; extraAuthHeader?: string }> { const { forceSsh, forceAnonymous } = opts; let cloneUrl = url; let cloneMethod: CloneResult['cloneMethod']; let extraAuthHeader: string | undefined; - if (forceSsh || isSshUrl(url)) { + if (provider === 'gitea') { + cloneUrl = parseGiteaRepoInput(url).httpsUrl; + if (cloneUrl.startsWith('ssh://')) { + cloneMethod = 'ssh'; + } else { + if (forceSsh) throw new Error('For Gitea SSH, provide an explicit ssh:// URL including the SSH port.'); + extraAuthHeader = forceAnonymous ? undefined : await giteaGitHeader() ?? undefined; + cloneMethod = extraAuthHeader ? 'https-token' : 'https-anonymous'; + } + } else if (forceSsh || isSshUrl(url)) { cloneUrl = isSshUrl(url) ? url : convertHttpToSsh(url); cloneMethod = 'ssh'; log.debug(`shallowClone: 使用 SSH 克隆 ${cloneUrl}`); @@ -253,7 +264,7 @@ export async function shallowClone( } await fs.ensureDir(localPath); - const { cloneUrl, cloneMethod, extraAuthHeader } = resolveCloneAuth(url, provider, { forceSsh, forceAnonymous }); + const { cloneUrl, cloneMethod, extraAuthHeader } = await resolveCloneAuth(url, provider, { forceSsh, forceAnonymous }); // 构建 clone 参数:若有 token 则通过 http.extraHeader 注入,避免 token 出现在 URL 中 const cloneArgs: string[] = []; @@ -306,7 +317,12 @@ export async function shallowClone( * in-URL OAuth credential from clone, and `git` / unknown hosts rely on ambient * Git credentials. */ -function fetchAuthHeader(provider?: string): string | null { +async function fetchAuthHeader(provider?: string, remote?: string): Promise { + if (provider === 'gitea') { + if (!remote) throw new Error('Gitea fetch requires the original repository URL.'); + const parsed = parseGiteaRepoInput(remote); + return parsed.httpsUrl.startsWith('ssh://') ? null : giteaGitHeader(); + } if (provider === 'github') { const token = getGitHubToken(); return token ? buildAuthHeader(token) : null; @@ -340,7 +356,8 @@ export async function shallowFetch( const branch = await gitCmd(['rev-parse', '--abbrev-ref', 'HEAD'], localPath, timeoutMs); // `-c http.extraHeader=` 必须置于 fetch 子命令之前(git-level 选项)。 - const authHeader = fetchAuthHeader(opts?.provider); + const remote = opts?.provider === 'gitea' ? await gitCmd(['remote', 'get-url', 'origin'], localPath, timeoutMs) : undefined; + const authHeader = await fetchAuthHeader(opts?.provider, remote); const fetchArgs = authHeader ? ['-c', `http.extraHeader=${authHeader}`, 'fetch', '--depth=50', 'origin'] : ['fetch', '--depth=50', 'origin']; @@ -373,9 +390,10 @@ export async function remoteHeadSha( .replace('refs/remotes/origin/', ''); } ref = `refs/heads/${branch}`; - auth = { cloneUrl: 'origin', extraAuthHeader: fetchAuthHeader(provider) ?? undefined }; + const remote = provider === 'gitea' ? await gitCmd(['remote', 'get-url', 'origin'], cachedRepoPath, timeoutMs) : undefined; + auth = { cloneUrl: 'origin', extraAuthHeader: await fetchAuthHeader(provider, remote) ?? undefined }; } else { - auth = resolveCloneAuth(url, provider, { + auth = await resolveCloneAuth(url, provider, { forceSsh: opts?.forceSsh ?? false, forceAnonymous: opts?.forceAnonymous ?? false, }); diff --git a/src/doctor.ts b/src/doctor.ts index c98f65cd9..1c479c33b 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -545,6 +545,18 @@ export async function buildChecks(ctx: DoctorContext, stage: CheckStage = 'docto fix: 'Export GITLAB_TOKEN (a Personal Access Token with `api` scope). ' + 'GITLAB_PRIVATE_TOKEN and GITLAB_PAT are accepted as aliases.', }); + } else if (providerName === 'gitea') { + const { giteaIsAuthenticated } = await import('./providers/gitea/index.js'); + const { giteaBaseUrl } = await import('./providers/gitea/repo-url.js'); + checks.push({ + name: 'Gitea instance URL is configured', source: 'provider', + check: async () => { try { giteaBaseUrl(); return true; } catch { return false; } }, + fix: 'Set GITEA_URL to the full HTTP(S) instance URL, including any port and subpath.', + }, { + name: 'Gitea token is configured', source: 'provider', + check: async () => { try { return giteaIsAuthenticated(); } catch { return false; } }, + fix: 'Set GITEA_TOKEN or run teamai init interactively to save a PAT in ~/.netrc.', + }); } else if (providerName === 'gitcode') { // GitCode needs no CLI — only a Personal Access Token (env or ~/.netrc). const { gitcodeIsAuthenticated } = await import('./providers/gitcode/index.js'); diff --git a/src/import-mr.ts b/src/import-mr.ts index ba9b75c4d..9790eb48a 100644 --- a/src/import-mr.ts +++ b/src/import-mr.ts @@ -7,12 +7,25 @@ import matter from 'gray-matter'; import { fetchGitHubPR } from './providers/github/mr-fetch.js'; import { fetchGitLabMR } from './providers/gitlab/mr-fetch.js'; import { fetchTGitMR } from './providers/tgit/mr-fetch.js'; +import { fetchGiteaMR } from './providers/gitea/mr-fetch.js'; +import { parseGiteaPrUrl } from './providers/gitea/repo-url.js'; import type { MRData, MRFetchOptions, MRThread, LearningDraft } from './types.js'; import { callClaude } from './utils/ai-client.js'; import { extractKeywords, findOverlappingLearnings } from './utils/dedup.js'; import { log, spinner } from './utils/logger.js'; import { getUserHome } from './utils/home.js'; +/** Keep a useful MR location without logging URL credentials or parameters. */ +export function mrUrlForLog(input: string): string { + try { + const url = new URL(input); + if (!['http:', 'https:'].includes(url.protocol)) return '[invalid MR URL]'; + return `${url.origin}${url.pathname}`; + } catch { + return '[invalid MR URL]'; + } +} + /** Default directory for storing learnings. */ const DEFAULT_LEARNINGS_DIR = path.join(getUserHome(), '.teamai', 'learnings'); @@ -25,6 +38,12 @@ const DEFAULT_LEARNINGS_DIR = path.join(getUserHome(), '.teamai', 'learnings'); * @throws Error when the URL does not belong to a known provider */ async function fetchMR(url: string, opts: MRFetchOptions): Promise { + // A validated instance is more specific than another platform's text hints. + let gitea = false; + if (process.env.GITEA_URL) { + try { parseGiteaPrUrl(url); gitea = true; } catch { /* Not this configured instance. */ } + } + if (gitea) return fetchGiteaMR(url, opts); if (url.includes('github.com')) { return fetchGitHubPR(url, opts); } @@ -36,7 +55,8 @@ async function fetchMR(url: string, opts: MRFetchOptions): Promise { if (/\/-\/merge_requests\/\d+/.test(url)) { return fetchGitLabMR(url, opts); } - throw new Error(`Unsupported MR URL: ${url}. Only GitHub, TGit and GitLab are supported`); + + throw new Error(`Unsupported MR URL: ${mrUrlForLog(url)}. Only GitHub, TGit, GitLab and configured Gitea instances are supported`); } /** Characters of the prompt the review threads may take (#1187). */ @@ -71,7 +91,7 @@ export function formatMrThreads(threads: MRThread[], mrAuthor: string | undefine let used = 0; for (const [index, thread] of ordered.entries()) { const anchor = thread.file ? ` — ${thread.file}${thread.line !== undefined ? `:${thread.line}` : ''}` : ''; - const state = thread.resolved === undefined ? '' : thread.resolved ? ' (resolved)' : ' (unresolved)'; + const state = thread.resolved === undefined ? ' (resolution unknown)' : thread.resolved ? ' (resolved)' : ' (unresolved)'; const header = `### Thread ${index + 1}${anchor}${state}`; const line = (note: MRThread['notes'][number]): string => { const role = note.author !== undefined && note.author === mrAuthor ? 'author' : 'reviewer'; @@ -115,6 +135,7 @@ export function extractMRLearningPrompt(mr: MRData, language?: 'en' | 'zh'): str const author = mr.author ?? 'unknown'; const date = mr.mergedAt ? mr.mergedAt.slice(0, 10) : new Date().toISOString().slice(0, 10); const threads = mr.discussions && mr.discussions.length > 0 ? formatMrThreads(mr.discussions, mr.author) : ''; + const reviewGuidance = 'A decision reached in a resolved thread outweighs a restatement of the diff only when its notes explicitly establish that decision. Review threads may contain comments, questions or decisions. State a decision only when the quoted notes explicitly support it. Resolution unknown means the platform did not provide a state; do not infer resolved from a missing label or from the MR being merged. Do not invent replies, agreements or resolution events.'; const frontmatter = (titleHint: string): string => `--- title: "${titleHint}" author: ${author} @@ -126,8 +147,8 @@ source_mr: "${mr.url}" if (language === 'en') { return `You are the team's knowledge base curator. Distill one valuable team learning from the MR below. -Write in English. Output a complete Markdown document with YAML frontmatter. -${threads ? '\nThe review threads carry the decisions: a decision reached in a resolved thread outweighs a restatement of the diff. Lead with what the review decided and why.\n' : ''} +Write in English. Output only a complete Markdown document with YAML frontmatter, without greetings, personal addresses, postscripts or offers to save it. +${threads ? `\n${reviewGuidance}\n` : ''} Frontmatter fields (exactly this format, no other fields): ${frontmatter('')} @@ -165,7 +186,8 @@ ${diff3000}${threads ? `\n\nReview threads (${mr.discussions!.length}):\n${threa return `你是团队知识库管理员。从以下 MR 信息提炼一条有价值的团队 learning。 【必须】用中文撰写,输出完整 Markdown 文档(含 YAML frontmatter)。 -${threads ? '\nReview threads 一节记录了评审达成的决定:已 resolved 的 thread 里的决定比复述 diff 更重要,优先写评审决定了什么、为什么。\n' : ''} +Output only the Markdown document, without greetings, personal addresses, postscripts or offers to save it. +${threads ? `\n${reviewGuidance}\n` : ''} frontmatter 字段(严格按此格式,不要加其他字段): ${frontmatter('<简短标题,描述核心问题或发现,<60字符>')} @@ -254,6 +276,9 @@ async function promptConfirm(question: string): Promise { * @returns Inferred repo .git URL */ function extractRepoUrlFromMrUrl(mrUrl: string): string { + if (process.env.GITEA_URL) { + try { return parseGiteaPrUrl(mrUrl).httpsUrl; } catch { /* Not the configured Gitea instance. */ } + } // GitHub: https://github.com/owner/repo/pull/123 → https://github.com/owner/repo.git const ghMatch = mrUrl.match(/^(https:\/\/github\.com\/[^/]+\/[^/]+)\/pull\//); if (ghMatch) return `${ghMatch[1]}.git`; @@ -315,6 +340,9 @@ export async function importFromMR(opts: { : 'MR data fetched'); } catch (err: unknown) { fetchSpinner.fail('MR data fetch failed'); + if (opts.url && err instanceof Error && err.message.includes(opts.url)) { + throw new Error(err.message.split(opts.url).join(mrUrlForLog(opts.url))); + } throw err; } diff --git a/src/import.ts b/src/import.ts index 8dc217931..a6bd5c64c 100644 --- a/src/import.ts +++ b/src/import.ts @@ -4,7 +4,7 @@ import fs from 'fs-extra'; import { autoDetectInit } from './config.js'; import { scanCandidates, classifyWithAI, interactiveReview, pushAccepted } from './import-local.js'; import { importFromIWiki } from './import-iwiki.js'; -import { importFromMR } from './import-mr.js'; +import { importFromMR, mrUrlForLog } from './import-mr.js'; import { importFromRepo } from './import-repo.js'; import { importFromRepoList } from './import-repo-list.js'; import { importFromOrg } from './import-org.js'; @@ -249,7 +249,8 @@ export async function importCmd(opts: ImportOptions): Promise { const failedMrs: Array<{ url: string; error: string }> = []; for (const [index, url] of mrUrls.entries()) { - if (mrUrls.length > 1) log.info(`[${index + 1}/${mrUrls.length}] ${url}`); + const safeUrl = mrUrlForLog(url); + if (mrUrls.length > 1) log.info(`[${index + 1}/${mrUrls.length}] ${safeUrl}`); try { const extracted = await importFromMR({ url, @@ -275,9 +276,9 @@ export async function importCmd(opts: ImportOptions): Promise { } catch (e: unknown) { // A single URL keeps reporting its failure as before. if (mrUrls.length === 1) throw e; - const message = e instanceof Error ? e.message : String(e); - log.warn(`import from MR failed: ${url} — ${message}`); - failedMrs.push({ url, error: message }); + const message = (e instanceof Error ? e.message : String(e)).split(url).join(safeUrl); + log.warn(`import from MR failed: ${safeUrl} — ${message}`); + failedMrs.push({ url: safeUrl, error: message }); } } diff --git a/src/index.ts b/src/index.ts index 932045d1e..2126a54c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -133,7 +133,7 @@ program .argument('[repo]', 'Team repo (owner/repo or full URL). Pass "." for single-repo mode (the current git repo is the team repo).') .option('--repo ', 'Team repo (alias of the positional argument)') .option('--http ', 'Git-free HTTP team repo (read-only consumer; only needs an API key)') - .option('--provider ', 'Git provider for the team repo on this machine: tgit, github, cnb, gitlab, gitcode, or git. Skips auto-detection. `git` uses your existing Git auth and needs no platform token, but opens no PR/MR.') + .option('--provider ', 'Git provider for the team repo on this machine: tgit, github, cnb, gitlab, gitcode, gitea, or git. Skips auto-detection. `git` uses your existing Git auth and needs no platform token, but opens no PR/MR.') .option('--self', 'Single-repo mode: the current git repo is the team repo (equivalent to `teamai init .`). Knowledge lives on main under .teamai/; reports go to the teamai-reports orphan branch.') .option('--token ', 'API key for HTTP team repo / status reporting (stored 0600, never committed). Also reads TEAMAI_API_TOKEN.') .option('--scope ', 'Install scope: workspace (default, /.teamai + /.claude) or user (~/.teamai + ~/.claude); project is the older name for workspace') diff --git a/src/init.ts b/src/init.ts index 6cefb5336..c2bd8247e 100644 --- a/src/init.ts +++ b/src/init.ts @@ -1777,7 +1777,7 @@ export async function init(options: GlobalOptions & { await settleReplacedInstall(); const cloneSpin = spinner('Cloning team repo...').start(); - const cloneTarget = provider.name === 'git' + const cloneTarget = provider.name === 'git' || provider.name === 'gitea' ? repoInfo.httpsUrl : `${repoInfo.owner}/${repoInfo.repo}`; try { diff --git a/src/providers/gitea/gitea-api.ts b/src/providers/gitea/gitea-api.ts new file mode 100644 index 000000000..951f18403 --- /dev/null +++ b/src/providers/gitea/gitea-api.ts @@ -0,0 +1,315 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import { getUserHome, legacyUserHome } from '../../utils/home.js'; +import path from 'node:path'; +import type { PrCreateOptions } from '../types.js'; +import { RepoCreatePermissionError } from '../types.js'; +import { sanitizeGitUrl } from '../../utils/redact.js'; +import { log } from '../../utils/logger.js'; +import { giteaApiBase, giteaBaseUrl, giteaRepoPath, parseGiteaRepoInput, parseGiteaPrUrl } from './repo-url.js'; + +// env / netrc → verified login → REST requests / persistent Git credentials. +const REQUEST_TIMEOUT_MS = 15_000; +const MAX_JSON_BYTES = 4 * 1024 * 1024; +const logins = new Map(); + +export class GiteaApiError extends Error { + constructor(readonly status: number) { + super(status === 401 ? 'Gitea token rejected: update GITEA_TOKEN or ~/.netrc.' + : status === 403 ? 'Gitea permission denied: check token scopes and repository access.' + : status === 404 ? 'Gitea resource not found or not accessible.' + : `Gitea API HTTP ${status}.`); + this.name = 'GiteaApiError'; + } +} + +function netrcFile(): string { return path.join(getUserHome(), '.netrc'); } + +/** Read a host-scoped netrc entry, including entries spread over several lines. */ +function netrcToken(): string | null { + if (!process.env.GITEA_URL?.trim()) return null; + const host = new URL(giteaBaseUrl()).hostname; + const legacy = legacyUserHome(); + for (const file of [netrcFile(), ...(legacy ? [path.join(legacy, '.netrc')] : [])]) { + try { + const tokens = fs.readFileSync(file, 'utf8').replace(/#[^\n]*/g, '').match(/\S+/g) ?? []; + let machine = ''; + for (let i = 0; i < tokens.length; i++) { + if (tokens[i] === 'machine') machine = tokens[++i]?.toLowerCase() ?? ''; + else if (tokens[i] === 'default' || tokens[i] === 'macdef') machine = ''; + else if (tokens[i] === 'password') { + const password = tokens[++i]; + if (machine === host && password) return password; + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } + return null; +} + +export function getGiteaToken(): string | null { + return process.env.GITEA_TOKEN?.trim() || netrcToken(); +} + +export function giteaIsAuthenticated(): boolean { return getGiteaToken() !== null; } + +export function writeGiteaNetrcToken(token: string, login: string): void { + if (!token || /\s/.test(token) || !login || /\s/.test(login)) throw new Error('Invalid Gitea credential.'); + const host = new URL(giteaBaseUrl()).hostname; + const file = netrcFile(); + let previous = ''; + try { previous = fs.readFileSync(file, 'utf8'); } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + const retained = previous.replace(/\bmachine\s+(\S+)[\s\S]*?(?=\bmachine\s+|\bdefault\b|\bmacdef\b|$)/g, + (entry, name: string) => name.toLowerCase() === host ? '' : entry).trimEnd(); + const entry = `machine ${host} login ${login} password ${token}\n`; + fs.writeFileSync(file, retained ? `${retained}\n${entry}` : entry, { mode: 0o600 }); + fs.chmodSync(file, 0o600); +} + +/** Strip both URL credentials and headers from Git failures, including timeouts. */ +export function redactGiteaOutput(text: string): string { + let result = sanitizeGitUrl(text).replace(/(Authorization:\s*(?:Basic|token)\s+)\S+/gi, '$1***'); + let token = process.env.GITEA_TOKEN?.trim(); + try { token ||= getGiteaToken() ?? undefined; } catch { /* Invalid config must not break error redaction. */ } + if (token) { + for (const secret of [token, encodeURIComponent(token)]) result = result.split(secret).join('***'); + } + return result; +} + +/** Cancel at the byte limit rather than buffering an unbounded response first. */ +export async function readGiteaText(response: Response, limit: number, truncate = false): Promise<{ text: string; truncated: boolean }> { + const reader = response.body?.getReader(); + if (!reader) return { text: '', truncated: false }; + const chunks: Uint8Array[] = []; + let remaining = limit; + let truncated = false; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value.byteLength > remaining) { + if (!truncate) throw new Error('Gitea API response exceeds the configured size limit.'); + chunks.push(value.subarray(0, remaining)); + truncated = true; + break; + } + chunks.push(value); + remaining -= value.byteLength; + } + } finally { + void reader.cancel().catch(() => {}); + } + // A streaming decoder holds an incomplete final UTF-8 sequence instead of emitting U+FFFD. + const decoder = new TextDecoder(); + const buffer = Buffer.concat(chunks); + const text = decoder.decode(buffer, { stream: truncated }); + return { text, truncated }; +} + +export async function giteaRequest( + route: string, + options: { method?: string; body?: unknown; token?: string; textLimit?: number } = {}, +): Promise<{ text: string; headers: Headers; truncated: boolean }> { + const api = `${giteaApiBase()}/`; + const url = new URL(route.replace(/^\//, ''), api); + if (!url.href.startsWith(api)) throw new Error('Invalid Gitea API route.'); + const token = options.token ?? getGiteaToken(); + if (!token) throw new Error('Gitea authentication unavailable. Set GITEA_TOKEN or run teamai init interactively.'); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const response = await fetch(url, { + method: options.method ?? 'GET', redirect: 'manual', signal: controller.signal, + headers: { Authorization: `token ${token}`, Accept: options.textLimit ? 'text/plain' : 'application/json', 'Content-Type': 'application/json' }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + if (!response.ok) { void response.body?.cancel().catch(() => {}); throw new GiteaApiError(response.status); } + return { ...await readGiteaText(response, options.textLimit ?? MAX_JSON_BYTES, options.textLimit !== undefined), headers: response.headers }; + } catch (error) { + if (error instanceof GiteaApiError) throw error; + throw new Error(`Gitea request failed: ${redactGiteaOutput((error as Error).message)}`); + } finally { + clearTimeout(timer); + controller.abort(); + } +} + +export async function giteaJson(route: string, options?: Parameters[1]): Promise { + const { text } = await giteaRequest(route, options); + if (!text.trim()) return undefined as T; + try { return JSON.parse(text) as T; } catch { throw new Error('Gitea API returned invalid JSON.'); } +} + +/** Follow pages even when the server clamps limit below the requested value. */ +export async function giteaList(route: string, maxItems?: number): Promise { + if (maxItems !== undefined && (!Number.isSafeInteger(maxItems) || maxItems < 0)) throw new Error('Invalid maxRepos: expected a non-negative integer.'); + const items: T[] = []; + let page = 1; + while (maxItems === undefined || items.length < maxItems) { + const url = new URL(route.replace(/^\//, ''), `${giteaApiBase()}/`); + url.searchParams.set('page', String(page)); + url.searchParams.set('limit', '50'); + const { text, headers } = await giteaRequest(url.href.slice(giteaApiBase().length)); + const batch: unknown = JSON.parse(text); + if (!Array.isArray(batch)) throw new Error('Gitea API returned an invalid list.'); + if (!batch.length) return items; + items.push(...batch as T[]); + if (maxItems !== undefined && items.length >= maxItems) { + log.warn(`Gitea repository list reached maxRepos=${maxItems}; additional results may be omitted.`); + return items.slice(0, maxItems); + } + // Gitea issue comments may ignore page while returning all results with a + // total count. Do not append that same complete list up to the page budget. + const link = headers.get('link'); + const totalHeader = headers.get('x-total-count'); + const total = totalHeader && /^\d+$/.test(totalHeader) ? Number(totalHeader) : undefined; + if (!link && total !== undefined && Number.isSafeInteger(total) && items.length >= total) return items; + const next = link?.match(/<([^>]+)>;\s*rel="?next"?/i)?.[1]; + if (link && !next) return items; + if (next) { + const nextUrl = new URL(next, url); + const nextPage = Number(nextUrl.searchParams.get('page')); + if (nextUrl.origin !== url.origin || nextUrl.pathname !== url.pathname || !Number.isSafeInteger(nextPage) || nextPage <= page) { + throw new Error('Gitea returned an invalid next-page link.'); + } + page = nextPage; + } else page++; + if (page > 1000) throw new Error('Gitea pagination budget exceeded; results are incomplete.'); + } + return items; +} + +export async function giteaWhoami(token = getGiteaToken()): Promise { + if (!token) throw new Error('Gitea authentication unavailable. Set GITEA_TOKEN.'); + const key = `${giteaBaseUrl()}\n${token}`; + const cached = logins.get(key); + if (cached) return cached; + const user = await giteaJson<{ login?: string }>('/user', { token }); + if (!user?.login) throw new Error('Gitea /user response did not include a login.'); + logins.set(key, user.login); + return user.login; +} + +// The provider's clone/org-existence interface is synchronous. A Node child keeps +// that contract without introducing a curl dependency or putting PATs in argv. +const SYNC_GET = ` +import fs from 'node:fs'; +const { url, token } = JSON.parse(fs.readFileSync(0, 'utf8')); +const r = await fetch(url, { headers: { Authorization: 'token ' + token }, redirect: 'manual', signal: AbortSignal.timeout(10000) }); +let size = 0; +const chunks = []; +if (r.body) for await (const chunk of r.body) { + size += chunk.length; + if (size > 1048576) throw new Error('Response too large'); + chunks.push(chunk); +} +process.stdout.write(JSON.stringify({ status: r.status, text: Buffer.concat(chunks).toString('utf8') })); +`; + +export function giteaGetSync(route: string): T { + const token = getGiteaToken(); + if (!token) throw new Error('Gitea authentication unavailable. Set GITEA_TOKEN.'); + const url = `${giteaApiBase()}${route}`; + const result = spawnSync(process.execPath, ['--input-type=module', '-e', SYNC_GET], { + input: JSON.stringify({ url, token }), encoding: 'utf8', timeout: 12_000, maxBuffer: 2 * 1024 * 1024, windowsHide: true, + }); + if (result.status !== 0) throw new Error('Gitea lookup failed or timed out. Check the instance URL and network.'); + const response = JSON.parse(result.stdout) as { status: number; text: string }; + if (response.status !== 200) throw new GiteaApiError(response.status); + return JSON.parse(response.text) as T; +} + +/** One-off HTTP authentication used by both shallow clone and incremental fetch. */ +export async function giteaGitHeader(): Promise { + const token = getGiteaToken(); + if (!token) return null; + const login = await giteaWhoami(token); + return `Authorization: Basic ${Buffer.from(`${login}:${token}`).toString('base64')}`; +} + +export function giteaRepoClone(repo: string, localPath: string): void { + const info = parseGiteaRepoInput(repo); + const url = new URL(info.httpsUrl); + const token = getGiteaToken(); + if (url.protocol !== 'ssh:' && token) { + const key = `${giteaBaseUrl()}\n${token}`; + const login = logins.get(key) ?? giteaGetSync<{ login: string }>('/user').login; + if (!login) throw new Error('Gitea /user response did not include a login.'); + url.username = login; + url.password = token; + } + const result = spawnSync('git', ['clone', url.href, localPath], { + encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 120_000, windowsHide: true, + }); + // Gitea deliberately hides inaccessible private repos behind 404. Do not turn + // that ambiguous failure into init's automatic create-repository prompt. + if (result.status !== 0) throw new Error(`Gitea git clone failed: ${redactGiteaOutput(`${result.stderr ?? ''} ${result.error?.message ?? ''}`).trim()}`); +} + +export async function giteaCreateRepo(owner: string, repo: string): Promise { + const info = parseGiteaRepoInput(`${owner}/${repo}`); + const login = await giteaWhoami(); + const personal = login.toLowerCase() === info.owner.toLowerCase(); + try { + await giteaJson(personal ? '/user/repos' : `/orgs/${encodeURIComponent(info.owner)}/repos`, { + method: 'POST', body: { name: info.repo, private: true, auto_init: false }, + }); + } catch (error) { + if (error instanceof GiteaApiError && error.status === 403) throw new RepoCreatePermissionError(`${owner}/${repo}`, `${giteaBaseUrl()}/repo/create`); + throw error; + } +} + +interface PullRequest { + number: number; + html_url: string; + head: { ref: string; repo?: { id?: number; full_name?: string } }; + base: { ref: string; repo?: { id?: number; full_name?: string } }; + requested_reviewers?: Array<{ login: string }>; +} + +export async function giteaPullCreate(options: PrCreateOptions): Promise { + const repo = parseGiteaRepoInput(options.repo); + const route = giteaRepoPath(options.repo); + let pull: PullRequest; + try { + pull = await giteaJson(`${route}/pulls`, { + method: 'POST', body: { head: options.source, base: options.target, title: options.title, body: options.description ?? '' }, + }); + } catch (error) { + // Only ambiguous transport failures and duplicate PR conflicts are recoverable. + if (error instanceof GiteaApiError && error.status !== 409) throw error; + const expected = `${repo.owner}/${repo.repo}`.toLowerCase(); + const matches = (await giteaList(`${route}/pulls?state=open`)).filter((pr) => + pr.head.ref === options.source && pr.base.ref === options.target + && pr.head.repo?.full_name?.toLowerCase() === expected && pr.base.repo?.full_name?.toLowerCase() === expected); + if (matches.length !== 1) throw error; + pull = matches[0]; + } + if (!Number.isSafeInteger(pull?.number) || pull.number <= 0 || !pull.html_url) throw new Error('Gitea PR response did not include a number and web URL.'); + const parsed = parseGiteaPrUrl(pull.html_url); + if (parsed.owner.toLowerCase() !== repo.owner.toLowerCase() || parsed.repo.toLowerCase() !== repo.repo.toLowerCase() || parsed.number !== pull.number) { + throw new Error('Gitea returned a PR URL for a different repository.'); + } + // Submit individually so one misspelled login cannot discard valid reviewers. + for (const reviewer of new Set(options.reviewers ?? [])) { + const alreadyRequested = (pr: PullRequest): boolean => pr.requested_reviewers?.some((user) => user.login.toLowerCase() === reviewer.toLowerCase()) ?? false; + if (alreadyRequested(pull)) continue; + try { + await giteaJson(`${route}/pulls/${pull.number}/requested_reviewers`, { method: 'POST', body: { reviewers: [reviewer] } }); + } catch (error) { + if (error instanceof GiteaApiError && [409, 422].includes(error.status)) { + try { + if (alreadyRequested(await giteaJson(`${route}/pulls/${pull.number}`))) continue; + } catch { /* Keep the original reviewer failure visible if verification fails. */ } + } + log.warn(`Gitea PR created at ${pull.html_url}, but reviewer ${reviewer} could not be requested: ${redactGiteaOutput((error as Error).message)}`); + } + } + return pull.html_url; +} diff --git a/src/providers/gitea/index.ts b/src/providers/gitea/index.ts new file mode 100644 index 000000000..ac5141fa2 --- /dev/null +++ b/src/providers/gitea/index.ts @@ -0,0 +1,43 @@ +import type { GitProvider, OrgRepoInfo, PrCreateOptions, RepoInfo } from '../types.js'; +import type { MRData, MRFetchOptions } from '../../types.js'; +import { askSecret, isInteractive } from '../../utils/prompt.js'; +import { log } from '../../utils/logger.js'; +import { giteaBaseUrl, parseGiteaRepoInput } from './repo-url.js'; +import { getGiteaToken, giteaIsAuthenticated, giteaWhoami, writeGiteaNetrcToken, giteaRepoClone, giteaCreateRepo, giteaPullCreate } from './gitea-api.js'; +import { giteaListOrgRepos, giteaOrganizationExists } from './org.js'; +import { fetchGiteaMR } from './mr-fetch.js'; + +// init / push / import → GiteaProvider → REST or Git transport. + +export class GiteaProvider implements GitProvider { + readonly name = 'gitea'; + + parseRepoInput(input: string): RepoInfo { return parseGiteaRepoInput(input); } + isAuthenticated(): boolean { return giteaIsAuthenticated(); } + + /** Verify configured credentials; only a missing token can start a TTY prompt. */ + async authenticate(): Promise { + giteaBaseUrl(); + if (getGiteaToken()) return giteaWhoami(); + if (!isInteractive()) throw new Error('Gitea authentication unavailable. Set GITEA_TOKEN before running without a TTY.'); + log.info(`Create a Personal Access Token at ${giteaBaseUrl()}/user/settings/applications.`); + const token = (await askSecret('Paste your Gitea Personal Access Token: ')).trim(); + if (!token) throw new Error('No token provided. Gitea authentication cancelled.'); + const login = await giteaWhoami(token); + writeGiteaNetrcToken(token, login); + log.success('Saved Gitea credentials to ~/.netrc'); + return login; + } + + async ensureInstalled(): Promise { /* Git and Node are already CLI prerequisites. */ } + cloneRepo(repo: string, localPath: string): void { giteaRepoClone(repo, localPath); } + async createRepo(owner: string, repo: string): Promise { await giteaCreateRepo(owner, repo); } + async createPullRequest(options: PrCreateOptions): Promise { return giteaPullCreate(options); } + async fetchMergeRequest(url: string, opts?: MRFetchOptions): Promise { return fetchGiteaMR(url, opts); } + async listOrgRepos(org: string, options?: { maxRepos?: number }): Promise { return giteaListOrgRepos(org, options); } + organizationExists(org: string): boolean { return giteaOrganizationExists(org); } + getOrganizationCreateUrl(): string { return `${giteaBaseUrl()}/org/create`; } + getDefaultEmailDomain(): null { return null; } +} + +export { giteaIsAuthenticated, getGiteaToken } from './gitea-api.js'; diff --git a/src/providers/gitea/mr-fetch.ts b/src/providers/gitea/mr-fetch.ts new file mode 100644 index 000000000..e09db44e6 --- /dev/null +++ b/src/providers/gitea/mr-fetch.ts @@ -0,0 +1,99 @@ +import type { MRData, MRFetchOptions, MRThread } from '../../types.js'; +import { pruneThreads } from '../mr-threads.js'; +import { log } from '../../utils/logger.js'; +import { giteaJson, giteaList, giteaRequest } from './gitea-api.js'; +import { giteaRepoPath, parseGiteaPrUrl } from './repo-url.js'; + +// Validated PR URL → metadata + paginated commits + bounded diff → MRData. + +interface GiteaPull { + title: string; + body?: string | null; + merged_at?: string | null; + user?: { login?: string }; +} + +interface GiteaCommit { sha: string; commit?: { message?: string } } + +/** Fail visibly when required evidence is unavailable instead of importing an empty diff. */ +export async function fetchGiteaMR(url: string, opts: MRFetchOptions = {}): Promise { + const info = parseGiteaPrUrl(url); + const route = `${giteaRepoPath(`${info.owner}/${info.repo}`)}/pulls/${info.number}`; + const pull = await giteaJson(route); + const commits = await giteaList(`${route}/commits`); + const patch = await giteaRequest(`${route}.diff`, { textLimit: 50 * 1024 }); + const marker = '\n[Diff truncated at 50 KiB; evidence is incomplete.]'; + let diff = patch.text; + if (patch.truncated) { + log.warn('Gitea PR diff truncated at 50 KiB; extracted knowledge has incomplete diff evidence.'); + // Keep the marker inside the same byte budget, including a UTF-8 boundary. + const decoder = new TextDecoder(); + diff = decoder.decode(Buffer.from(diff).subarray(0, 50 * 1024 - Buffer.byteLength(marker)), { stream: true }) + marker; + } + const discussions = opts.discussions === false ? undefined : await fetchDiscussions(route, opts.commentMarker); + return { + discussions, + title: pull.title, description: pull.body ?? '', author: pull.user?.login, + mergedAt: pull.merged_at ?? undefined, url, + commits: commits.map((commit) => ({ hash: commit.sha, message: commit.commit?.message ?? '' })), diff, + }; +} + +/** Keep source provenance without changing the shared extraction contract. */ +interface GiteaThread extends MRThread { + sourceId: string; + createdAt?: string; +} +interface GiteaComment { + id: number; + body?: string | null; + user?: { login?: string }; + created_at?: string; + path?: string; + resolver?: { login?: string } | null; +} +interface GiteaReview { + id: number; + body?: string | null; + user?: { login?: string }; + state?: string; + submitted_at?: string; +} + +/** + * Gitea's public review comment contract has no reply/parent id. Preserve each + * comment independently: path and diff position do not prove a reply relation, + * and a diff position is not a source line number. Optional evidence failures + * must not erase metadata, commits, the diff, or other successful discussions. + */ +async function fetchDiscussions(route: string, marker?: string): Promise { + const threads: GiteaThread[] = []; + const append = (comment: GiteaComment, source: string) => { + threads.push({ sourceId: `${source}:${comment.id}`, createdAt: comment.created_at, + file: comment.path, + resolved: comment.resolver === undefined ? undefined : comment.resolver !== null, + notes: [{ author: comment.user?.login, body: comment.body ?? '' }] }); + }; + // Swagger declares since/before for issue comments and no pagination for + // review comments. Only the reviews collection accepts page/limit. + try { + const comments = await giteaJson(route.replace('/pulls/', '/issues/') + '/comments'); + for (const comment of comments) append(comment, 'issue'); + } catch { log.warn('Gitea issue comments unavailable; imported review evidence is incomplete.'); } + try { + const reviews = await giteaList(`${route}/reviews`); + for (const review of reviews) { + if (['PENDING', 'REQUEST_REVIEW'].includes(review.state ?? '')) continue; + threads.push({ sourceId: `review:${review.id}`, createdAt: review.submitted_at, + notes: [{ author: review.user?.login, body: review.body ?? '' }] }); + try { + const comments = await giteaJson(`${route}/reviews/${review.id}/comments`); + for (const comment of comments) append(comment, `review:${review.id}:comment`); + } catch { log.warn(`Gitea review ${review.id} comments unavailable; imported review evidence is incomplete.`); } + } + } catch { log.warn('Gitea reviews unavailable; imported review evidence is incomplete.'); } + // Stable date/id ordering retains provenance and avoids inventing reply links. + const unique = [...new Map(threads.map(thread => [thread.sourceId, thread])).values()]; + unique.sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? '') || a.sourceId.localeCompare(b.sourceId)); + return pruneThreads(unique, marker); +} diff --git a/src/providers/gitea/org.ts b/src/providers/gitea/org.ts new file mode 100644 index 000000000..fc3f3836a --- /dev/null +++ b/src/providers/gitea/org.ts @@ -0,0 +1,43 @@ +import type { OrgRepoInfo } from '../types.js'; +import { GiteaApiError, giteaGetSync, giteaList } from './gitea-api.js'; +import { parseGiteaRepoInput } from './repo-url.js'; + +// Organization → paginated REST repositories → provider-neutral metadata. + +interface GiteaRepo { + name: string; + full_name: string; + clone_url: string; + description?: string; + language?: string; + archived?: boolean; + stars_count?: number; + updated_at?: string; +} + +function orgName(org: string): string { + return parseGiteaRepoInput(`${org}/validation`).owner; +} + +export async function giteaListOrgRepos(org: string, options?: { maxRepos?: number }): Promise { + const repos = await giteaList(`/orgs/${encodeURIComponent(orgName(org))}/repos`, options?.maxRepos ?? 200); + return repos.map((repo) => { + // Validate API-supplied clone URLs before another command attaches a PAT. + const info = parseGiteaRepoInput(repo.clone_url); + return { + url: info.httpsUrl, fullName: repo.full_name, name: repo.name, + description: repo.description || undefined, primaryLanguage: repo.language || undefined, + archived: repo.archived ?? false, stars: repo.stars_count, pushedAt: repo.updated_at, + }; + }); +} + +export function giteaOrganizationExists(org: string): boolean { + try { + giteaGetSync(`/orgs/${encodeURIComponent(orgName(org))}`); + return true; + } catch (error) { + if (error instanceof GiteaApiError && error.status === 404) return false; + throw error; + } +} diff --git a/src/providers/gitea/probe.ts b/src/providers/gitea/probe.ts new file mode 100644 index 000000000..85947753e --- /dev/null +++ b/src/providers/gitea/probe.ts @@ -0,0 +1,49 @@ +import { readGiteaText } from './gitea-api.js'; + +// Unknown HTTP remote → anonymous version probes → explicit configuration hint. + +/** A compatible version endpoint is a hint, not permission to send a PAT. */ +export async function probeSelfHostedGitea( + input: string, + timeoutMs = 3_000, +): Promise<{ baseUrl: string; version: string } | null> { + let url: URL; + try { + url = new URL(input); + } catch { + return null; + } + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) return null; + const parts = url.pathname.replace(/^\/+|\/+$/g, '').split('/'); + if (parts.length < 2 || parts.some((part) => !part || /[\s\\]/.test(part))) return null; + const suffix = parts.at(-2) === 'pulls' && /^\d+$/.test(parts.at(-1) ?? '') ? 4 : 2; + if (parts.length < suffix) return null; + const controller = new AbortController(); + let timer: ReturnType; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => { controller.abort(); resolve(null); }, timeoutMs); + }); + const probe = async (): Promise<{ baseUrl: string; version: string } | null> => { + for (let length = parts.length - suffix; length >= 0; length--) { + const baseUrl = url.origin + (length ? `/${parts.slice(0, length).join('/')}` : ''); + try { + const response = await fetch(`${baseUrl}/api/v1/version`, { + headers: { Accept: 'application/json' }, credentials: 'omit', redirect: 'manual', signal: controller.signal, + }); + if (response.status !== 200) { void response.body?.cancel().catch(() => {}); continue; } + const { text } = await readGiteaText(response, 128 * 1024); + const data = JSON.parse(text) as { version?: unknown }; + if (typeof data.version === 'string' && data.version.trim()) return { baseUrl, version: data.version }; + } catch { + if (controller.signal.aborted) return null; + } + } + return null; + }; + try { + return await Promise.race([probe(), timeout]); + } finally { + clearTimeout(timer!); + controller.abort(); + } +} diff --git a/src/providers/gitea/repo-url.ts b/src/providers/gitea/repo-url.ts new file mode 100644 index 000000000..bf037eee7 --- /dev/null +++ b/src/providers/gitea/repo-url.ts @@ -0,0 +1,105 @@ +import type { RepoInfo } from '../types.js'; + +// GITEA_URL → instance boundary → repository / PR → canonical transport URL. + +/** Resolve the configured instance without losing its scheme, port or subpath. */ +export function giteaBaseUrl(): string { + const raw = process.env.GITEA_URL?.trim(); + if (!raw) throw new Error('Set GITEA_URL to the full Gitea instance URL, e.g. http://gitea.example.com:3000.'); + let url: URL; + try { + url = new URL(raw); + } catch { + throw new Error('Invalid GITEA_URL: expected a full HTTP(S) instance URL.'); + } + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) { + throw new Error('Invalid GITEA_URL: use HTTP(S) without credentials, query or fragment.'); + } + const override = process.env.TEAMAI_GITEA_HOST?.trim().toLowerCase(); + if (override && override !== url.host && override !== url.hostname) { + throw new Error('TEAMAI_GITEA_HOST and GITEA_URL must name the same instance host. SSH aliases are not supported.'); + } + return url.href.replace(/\/+$/, ''); +} + +export function giteaApiBase(): string { + return `${giteaBaseUrl()}/api/v1`; +} + +/** Detection is only a routing hint; parsing below checks the complete boundary. */ +export function isConfiguredGiteaHost(host: string): boolean { + const raw = process.env.GITEA_URL?.trim(); + const override = process.env.TEAMAI_GITEA_HOST?.trim().toLowerCase(); + try { + const url = raw ? new URL(giteaBaseUrl()) : null; + return [url?.host, url?.hostname, override].some((candidate) => candidate === host.toLowerCase()); + } catch { + return false; + } +} + +function component(value: string): string { + const decoded = decodeURIComponent(value); + if (!decoded || decoded === '.' || decoded === '..' || /[\s/\\?#]/.test(decoded)) { + throw new Error('Invalid Gitea owner or repository name.'); + } + return decoded; +} + +/** Validate HTTP URLs before any token or Git header can be sent. */ +export function assertGiteaHttpUrl(input: string): URL { + const base = new URL(giteaBaseUrl()); + const url = new URL(input); + const prefix = base.pathname.replace(/\/+$/, ''); + if (url.origin !== base.origin || url.username || url.password || url.search || url.hash + || !url.pathname.startsWith(`${prefix}/`)) { + throw new Error('Gitea URL does not match the configured instance origin and base path.'); + } + return url; +} + +/** Parse a single-level owner/repo, HTTP(S), ssh:// or scp-style remote. */ +export function parseGiteaRepoInput(input: string): RepoInfo { + const raw = input.trim(); + const base = new URL(giteaBaseUrl()); + let path = raw; + let ssh: URL | undefined; + if (/^https?:\/\//i.test(raw)) { + const url = assertGiteaHttpUrl(raw); + path = url.pathname.slice(base.pathname.replace(/\/+$/, '').length + 1); + } else if (/^ssh:\/\//i.test(raw) || /^[^@\s/:]+@[^:\s/]+:/.test(raw)) { + const scp = raw.match(/^([^@\s/:]+)@([^:\s/]+):(.+)$/); + ssh = new URL(scp ? `ssh://${scp[1]}@${scp[2]}/${scp[3]}` : raw); + if (ssh.protocol !== 'ssh:' || ssh.hostname !== base.hostname || ssh.password || ssh.search || ssh.hash) { + throw new Error('Gitea SSH URL must use the configured hostname without a password.'); + } + path = ssh.pathname.replace(/^\//, ''); + } else if (raw.includes('://') || raw.includes('@') || raw.startsWith('/')) { + throw new Error('Invalid Gitea repository URL.'); + } + const segments = path.replace(/\/+$/, '').split('/'); + if (segments.length !== 2 && !(segments.length === 4 && segments[2] === 'pulls' && /^[1-9]\d*$/.test(segments[3]))) { + throw new Error('Invalid Gitea repository: expected owner/repo or an instance URL ending in /pulls/.'); + } + const owner = component(segments[0]); + const repo = component(segments[1].replace(/\.git$/i, '')); + const projectId = `${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + let cloneUrl = `${giteaBaseUrl()}/${projectId}.git`; + if (ssh) { + ssh.pathname = `/${projectId}.git`; + cloneUrl = ssh.href; + } + return { owner, repo, projectId, httpsUrl: cloneUrl }; +} + +export function parseGiteaPrUrl(input: string): RepoInfo & { number: number } { + const url = assertGiteaHttpUrl(input); + const match = url.pathname.match(/\/pulls\/([1-9]\d*)\/?$/); + if (!match || !Number.isSafeInteger(Number(match[1]))) throw new Error('Invalid Gitea PR URL.'); + return { ...parseGiteaRepoInput(input), number: Number(match[1]) }; +} + +/** Build API paths from parsed names, never from an unchecked absolute URL. */ +export function giteaRepoPath(repo: string): string { + return `/repos/${parseGiteaRepoInput(repo).projectId}`; +} diff --git a/src/providers/index.ts b/src/providers/index.ts index 3830fa1c4..23f7f716b 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -5,4 +5,5 @@ export { TGitProvider } from './tgit/index.js'; export { GitHubProvider } from './github/index.js'; export { GitLabProvider } from './gitlab/index.js'; export { GitCodeProvider } from './gitcode/index.js'; +export { GiteaProvider } from './gitea/index.js'; export { GenericGitProvider } from './git/index.js'; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 36057530f..f478d4565 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -4,6 +4,9 @@ import { GitHubProvider } from './github/index.js'; import { CNBProvider } from './cnb/index.js'; import { GitLabProvider } from './gitlab/index.js'; import { GitCodeProvider } from './gitcode/index.js'; +import { GiteaProvider } from './gitea/index.js'; +import { isConfiguredGiteaHost } from './gitea/repo-url.js'; +import { probeSelfHostedGitea } from './gitea/probe.js'; import { GenericGitProvider } from './git/index.js'; import { getCurrentPackageName } from '../package-info.js'; import { probeSelfHostedGitLab } from './gitlab/probe.js'; @@ -39,7 +42,7 @@ const HOST_MAP: Record = { }; /** Providers we are willing to accept as a default override. */ -const KNOWN_PROVIDERS = new Set(['github', 'tgit', 'cnb', 'gitlab', 'gitcode']); +const KNOWN_PROVIDERS = new Set(['github', 'tgit', 'cnb', 'gitlab', 'gitcode', 'gitea']); /** * Decide the fallback provider used when the input URL host is unknown or @@ -125,6 +128,10 @@ export async function detectProviderForInit(input: string): Promise { + 'and GITLAB_TOKEN (a Personal Access Token with api scope), then run teamai init again.', ); } + const gitea = await probeSelfHostedGitea(input); + if (gitea) { + throw new Error(`Detected a Gitea-compatible instance at ${gitea.baseUrl}. Set GITEA_URL=${gitea.baseUrl} and GITEA_TOKEN, then run teamai init again.`); + } return provider; } @@ -134,7 +141,7 @@ export async function detectProviderForInit(input: string): Promise { * provider for arbitrary hosts. */ function resolveHostProvider(host: string): string { - return HOST_MAP[host] ?? detectSelfHostedGitLabHost(host) ?? 'git'; + return HOST_MAP[host] ?? detectSelfHostedGitLabHost(host) ?? (isConfiguredGiteaHost(host) ? 'gitea' : 'git'); } /** @@ -188,6 +195,7 @@ const PROVIDERS: Record GitProvider> = { cnb: () => new CNBProvider(), gitlab: () => new GitLabProvider(), gitcode: () => new GitCodeProvider(), + gitea: () => new GiteaProvider(), git: () => new GenericGitProvider(), }; diff --git a/src/types.ts b/src/types.ts index be832ed0f..21a0a6a6c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -383,7 +383,7 @@ export const SOURCE_PULL_TTL_MS = 24 * 60 * 60 * 1000; export const TEAMAI_SOURCES_DIR = path.join(getUserHome(), '.teamai', 'sources'); /** Git hosting provider. `git` is the transport-only fallback for arbitrary hosts. */ -export const ProviderNameSchema = z.enum(['tgit', 'github', 'cnb', 'gitlab', 'gitcode', 'git']); +export const ProviderNameSchema = z.enum(['tgit', 'github', 'cnb', 'gitlab', 'gitcode', 'gitea', 'git']); export type ProviderName = z.infer; /** One team-owned Node entrypoint. `path` is relative to the team repo root. */