feat(webui): worker registry and configuration schema (Closes #798) #810

Merged
sysadmin merged 1 commits from feat/issue-798-worker-registry-schema into master 2026-07-22 06:48:29 -05:00
Owner

Closes #798

Parent epic: #797. This pull request implements child #798 only. It does not complete the epic, and #797 must stay open.

Problem

Epic #797 needs one control surface for the scheduled multi-LLM worker fleet (Claude, Grok, Codex, AGY, Kimi K). Every other child depends on knowing what a worker is: #799 renders registered workers, #801 validates them, #802 edits them, #803 and #804 read their schedule and scheduler metadata, #805 targets one by id, #806 keys run records to them, #807 enforces their namespace binding, #808 migrates existing ones into them. None of that can be built against an undefined record.

Nothing in the tree defined one. rg -l -i 'worker_registry|WorkerRegistry' returned no files before this change. webui/project_registry.py is a different entity (git projects and onboarding) with no provider or worker concept and no write path.

What changed

webui/worker_registry.py (new, 647 lines). The data model, its validation, and its persistence.

Two separate entity kinds. A provider describes an LLM runtime — vendor, executable, models, availability — and exists whether or not any worker uses it. A worker is a configured instance binding one provider, model, project, role, namespace, profile, workflow, schedule, timeout, enabled state, and scheduler record. Separating them is what lets #799 list all five providers when a provider has no worker configured, and it stops provider facts being copied into (and drifting across) every worker row.

Validation fails closed. Unknown fields are refused rather than ignored — a typo'd timeout_second would otherwise read as "no timeout declared". A worker naming an undeclared provider is rejected, because a record that looks configured but is unrunnable is exactly the ambiguous ownership the epic requires to fail closed. Worker ids, provider ids, and LaunchAgent labels are each unique; two workers sharing a launchd label would silently overwrite each other's agent. Roles are constrained to the sanctioned set, since the role selects the namespace whose capability gates bind the worker. Timeouts must be positive integers bounded at 24h, so a worker cannot be configured to hold a lease indefinitely. Schedules are validated per kind (interval needs positive seconds, cron needs five crontab fields, manual needs neither) and fields belonging to a different kind are refused.

Atomic persistence. Temp file in the same directory, fsync, then os.replace — the same idiom as issue_lock_store.save_lock_file. An invalid document is refused before any write, so a failed save cannot leave a partial file.

Versioning and rollback. Each save archives the superseded document under its own revision number and writes the successor at revision + 1. rollback_to_revision republishes a retained revision as a new head rather than rewinding, so history is append-only and a rollback is itself reversible. History is capped at 20 revisions.

webui/registry_safety.py (new, 52 lines). The credential-rejection guard, extracted so the two registries share one implementation. Registries are operator-editable files the browser layer reads, so none may carry a secret; the check refuses the shape of a credential key at any depth rather than guessing at values.

webui/project_registry.py. Refactored to import that guard, deleting its module-private copy. Behaviour is identical and no public symbol changed — only the private _FORBIDDEN_* constants, _is_forbidden_key, and the local _reject_credential_keys body were removed. The existing test_registry_rejects_credential_keys covers the swap and still passes. Stated plainly for the reviewer: this is the one file in the diff not created by this issue, and it is touched to avoid shipping a second copy of a security check.

webui/data/workers.registry.json (new). The five providers of #797, with available reflecting an actual PATH probe (claude, grok, codex, agy, kimi all present; note the Kimi K provider id is kimi-k while its executable is kimi). The workers array ships empty on purpose — populating it is #808, and inventing worker records here would assert fleet state that does not exist.

Acceptance criteria

AC Where satisfied
Declarative registry is the source of truth load_registry / packaged webui/data/workers.registry.json; TestPackagedRegistry
Providers and configured workers are separate entities ProviderRecord vs WorkerRecord; TestSeparateEntities (provider with no workers, many workers per provider, unknown-provider refusal)
Records provider, model, project, role, namespace/profile, workflow, schedule, timeout, enabled, scheduler metadata WorkerRecord; TestRecordedFields, including a subtest asserting each field is individually required
Schema validation validate_payload; TestSchemaValidation, TestScheduleValidation
Atomic persistence _atomic_write / save_registry; TestAtomicPersistence
Versioning and rollback save_registry, list_revisions, rollback_to_revision; TestVersioningAndRollback
Includes automated tests tests/test_webui_worker_registry.py, 44 cases

Tests and results

Run with venv/bin/python -m pytest from the allocated worktree.

  • New suite tests/test_webui_worker_registry.py44 passed, 38 subtests passed.
  • Web UI subsystem (-k webui, includes the refactored project registry) — 149 passed, 144 subtests passed.
  • Full suite — 4306 passed, 11 failed, 6 skipped, 531 subtests passed in 74s.

Baseline comparison: a detached worktree pinned to master 5032965e3a39654f1d65b2a58f25931bc72ff17b — the exact base of this branch — reports 4262 passed, 11 failed, 6 skipped, 493 subtests passed running the identical command. The eleven failing node identifiers are byte-for-byte the same on both: six in test_commit_payloads.py, two in test_issue_702_review_findings_f1_f6.py, and one each in test_mcp_server.py::TestPreflightVerification, test_post_merge_moot_lease.py, and test_reconciler_supersession_close.py. All are pre-existing author-worktree-binding and preflight fixtures; none touches the web UI. The pass delta 4262 to 4306 is exactly the 44 new cases, so this branch adds no new failures. The baseline worktree was removed after the run.

Preserved behaviour

The web UI is untouched: no route added or changed, no template, no bind logic. webui/deployment_boundary.py and its loopback-only protections are not modified — this change adds no network surface at all. The registry is a file the module reads and writes; nothing here serves it over HTTP. That is #799.

Risk

Low. The only existing file modified is project_registry.py, and only to delegate a private helper to an identical extracted implementation, proven by its own unchanged test. Everything else is new and reachable only from the new test suite until #799 wires it up.

Known limitations

available on a provider is an operator declaration, not a live probe; executable discovery, version inspection, and auth-state checks belong to #800. Next-run computation from a schedule belongs to #803. No worker is registered yet, by design (#808). The history cap of 20 revisions is a constant rather than configuration.

Provenance

  • Issue: #798, parent epic Centralized management for scheduled multi-LLM workers (#797)
  • Branch: feat/issue-798-worker-registry-schema
  • Worktree: /Users/jasonwalker/Development/Gitea-Tools/branches/issue-798-worker-registry-schema
  • Published head: 5463f589334f6a9520e2512a3c33929976d924ac
  • Base: master at 5032965e3a39654f1d65b2a58f25931bc72ff17b
  • Content was published through gitea_commit_files from the worktree, so the local worktree commit 3afe0b2 and the published head are siblings carrying identical trees; the published head above is authoritative for review.
Closes #798 Parent epic: #797. This pull request implements child #798 only. It does not complete the epic, and #797 must stay open. ## Problem Epic #797 needs one control surface for the scheduled multi-LLM worker fleet (Claude, Grok, Codex, AGY, Kimi K). Every other child depends on knowing what a worker *is*: #799 renders registered workers, #801 validates them, #802 edits them, #803 and #804 read their schedule and scheduler metadata, #805 targets one by id, #806 keys run records to them, #807 enforces their namespace binding, #808 migrates existing ones into them. None of that can be built against an undefined record. Nothing in the tree defined one. `rg -l -i 'worker_registry|WorkerRegistry'` returned no files before this change. `webui/project_registry.py` is a different entity (git projects and onboarding) with no provider or worker concept and no write path. ## What changed **`webui/worker_registry.py` (new, 647 lines).** The data model, its validation, and its persistence. *Two separate entity kinds.* A **provider** describes an LLM runtime — vendor, executable, models, availability — and exists whether or not any worker uses it. A **worker** is a configured instance binding one provider, model, project, role, namespace, profile, workflow, schedule, timeout, enabled state, and scheduler record. Separating them is what lets #799 list all five providers when a provider has no worker configured, and it stops provider facts being copied into (and drifting across) every worker row. *Validation fails closed.* Unknown fields are refused rather than ignored — a typo'd `timeout_second` would otherwise read as "no timeout declared". A worker naming an undeclared provider is rejected, because a record that looks configured but is unrunnable is exactly the ambiguous ownership the epic requires to fail closed. Worker ids, provider ids, and LaunchAgent labels are each unique; two workers sharing a launchd label would silently overwrite each other's agent. Roles are constrained to the sanctioned set, since the role selects the namespace whose capability gates bind the worker. Timeouts must be positive integers bounded at 24h, so a worker cannot be configured to hold a lease indefinitely. Schedules are validated per kind (`interval` needs positive seconds, `cron` needs five crontab fields, `manual` needs neither) and fields belonging to a different kind are refused. *Atomic persistence.* Temp file in the same directory, `fsync`, then `os.replace` — the same idiom as `issue_lock_store.save_lock_file`. An invalid document is refused before any write, so a failed save cannot leave a partial file. *Versioning and rollback.* Each save archives the superseded document under its own revision number and writes the successor at `revision + 1`. `rollback_to_revision` republishes a retained revision as a **new head** rather than rewinding, so history is append-only and a rollback is itself reversible. History is capped at 20 revisions. **`webui/registry_safety.py` (new, 52 lines).** The credential-rejection guard, extracted so the two registries share one implementation. Registries are operator-editable files the browser layer reads, so none may carry a secret; the check refuses the *shape* of a credential key at any depth rather than guessing at values. **`webui/project_registry.py`.** Refactored to import that guard, deleting its module-private copy. Behaviour is identical and no public symbol changed — only the private `_FORBIDDEN_*` constants, `_is_forbidden_key`, and the local `_reject_credential_keys` body were removed. The existing `test_registry_rejects_credential_keys` covers the swap and still passes. Stated plainly for the reviewer: this is the one file in the diff not created by this issue, and it is touched to avoid shipping a second copy of a security check. **`webui/data/workers.registry.json` (new).** The five providers of #797, with `available` reflecting an actual PATH probe (`claude`, `grok`, `codex`, `agy`, `kimi` all present; note the Kimi K provider id is `kimi-k` while its executable is `kimi`). The `workers` array ships **empty on purpose** — populating it is #808, and inventing worker records here would assert fleet state that does not exist. ## Acceptance criteria | AC | Where satisfied | |----|-----------------| | Declarative registry is the source of truth | `load_registry` / packaged `webui/data/workers.registry.json`; `TestPackagedRegistry` | | Providers and configured workers are separate entities | `ProviderRecord` vs `WorkerRecord`; `TestSeparateEntities` (provider with no workers, many workers per provider, unknown-provider refusal) | | Records provider, model, project, role, namespace/profile, workflow, schedule, timeout, enabled, scheduler metadata | `WorkerRecord`; `TestRecordedFields`, including a subtest asserting each field is individually required | | Schema validation | `validate_payload`; `TestSchemaValidation`, `TestScheduleValidation` | | Atomic persistence | `_atomic_write` / `save_registry`; `TestAtomicPersistence` | | Versioning and rollback | `save_registry`, `list_revisions`, `rollback_to_revision`; `TestVersioningAndRollback` | | Includes automated tests | `tests/test_webui_worker_registry.py`, 44 cases | ## Tests and results Run with `venv/bin/python -m pytest` from the allocated worktree. - New suite `tests/test_webui_worker_registry.py` — **44 passed, 38 subtests passed**. - Web UI subsystem (`-k webui`, includes the refactored project registry) — **149 passed, 144 subtests passed**. - Full suite — **4306 passed, 11 failed, 6 skipped, 531 subtests passed** in 74s. Baseline comparison: a detached worktree pinned to master `5032965e3a39654f1d65b2a58f25931bc72ff17b` — the exact base of this branch — reports **4262 passed, 11 failed, 6 skipped, 493 subtests passed** running the identical command. The eleven failing node identifiers are byte-for-byte the same on both: six in `test_commit_payloads.py`, two in `test_issue_702_review_findings_f1_f6.py`, and one each in `test_mcp_server.py::TestPreflightVerification`, `test_post_merge_moot_lease.py`, and `test_reconciler_supersession_close.py`. All are pre-existing author-worktree-binding and preflight fixtures; none touches the web UI. The pass delta 4262 to 4306 is exactly the 44 new cases, so this branch adds no new failures. The baseline worktree was removed after the run. ## Preserved behaviour The web UI is untouched: no route added or changed, no template, no bind logic. `webui/deployment_boundary.py` and its loopback-only protections are not modified — this change adds no network surface at all. The registry is a file the module reads and writes; nothing here serves it over HTTP. That is #799. ## Risk Low. The only existing file modified is `project_registry.py`, and only to delegate a private helper to an identical extracted implementation, proven by its own unchanged test. Everything else is new and reachable only from the new test suite until #799 wires it up. ## Known limitations `available` on a provider is an operator declaration, not a live probe; executable discovery, version inspection, and auth-state checks belong to #800. Next-run computation from a schedule belongs to #803. No worker is registered yet, by design (#808). The history cap of 20 revisions is a constant rather than configuration. ## Provenance - Issue: #798, parent epic #797 - Branch: `feat/issue-798-worker-registry-schema` - Worktree: `/Users/jasonwalker/Development/Gitea-Tools/branches/issue-798-worker-registry-schema` - Published head: `5463f589334f6a9520e2512a3c33929976d924ac` - Base: master at `5032965e3a39654f1d65b2a58f25931bc72ff17b` - Content was published through `gitea_commit_files` from the worktree, so the local worktree commit `3afe0b2` and the published head are siblings carrying identical trees; the published head above is authoritative for review.
jcwalker3 added 1 commit 2026-07-22 06:11:35 -05:00
Add the declarative worker registry that epic #797 makes the source of
truth for the scheduled multi-LLM worker fleet.

Providers and configured workers are modelled as separate entities so a
provider can be listed with no worker configured, and so provider facts
are not copied into every worker record. A worker records provider,
model, project, role, namespace, profile, workflow, schedule, timeout,
enabled state, and scheduler metadata.

Validation fails closed: unknown fields are refused rather than ignored,
so a typo cannot silently disable a timeout; a worker naming an
undeclared provider is rejected; worker ids, provider ids, and
LaunchAgent labels must be unique.

Persistence is atomic (temp file in the same directory, fsync, replace).
Every superseded document is retained as a numbered revision, and
rollback republishes a chosen revision as a new head, so history stays
append-only and a rollback is itself reversible.

The credential-rejection guard is extracted to webui/registry_safety.py
so both registries share one implementation instead of two copies of a
security check; project_registry.py keeps identical behaviour.

Scope: data model, validation, persistence only. No routes, scheduler,
process control, or provider probing - those are #799/#800/#804/#805.
The workers array ships empty because populating it is #808.

Tests: tests/test_webui_worker_registry.py, 44 cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Owner

repo: Scaled-Tech-Consulting/Gitea-Tools
pr: #810
issue: #798
reviewer_identity: sysadmin
profile: prgs-reviewer
session_id: 36068-f6679735fdeb
worktree: /Users/jasonwalker/Development/Gitea-Tools/branches/review-pr-810-session
phase: claimed
candidate_head: 5463f58933
target_branch: master
target_branch_sha: 0b29404031
last_activity: 2026-07-22T11:45:58Z
expires_at: 2026-07-22T11:55:58Z
blocker: none

<!-- mcp-review-lease:v1 --> repo: Scaled-Tech-Consulting/Gitea-Tools pr: #810 issue: #798 reviewer_identity: sysadmin profile: prgs-reviewer session_id: 36068-f6679735fdeb worktree: /Users/jasonwalker/Development/Gitea-Tools/branches/review-pr-810-session phase: claimed candidate_head: 5463f589334f6a9520e2512a3c33929976d924ac target_branch: master target_branch_sha: 0b294040314ecf661dcd010cb55a05d923233a95 last_activity: 2026-07-22T11:45:58Z expires_at: 2026-07-22T11:55:58Z blocker: none
sysadmin approved these changes 2026-07-22 06:47:54 -05:00
sysadmin left a comment
Owner

Canonical PR State

STATE: approved
WHO_IS_NEXT: merger
NEXT_ACTION: Merge PR #810 at pinned head 5463f58933 if merger gates pass
NEXT_PROMPT:

Merge PR #810 (Closes #798) on prgs Scaled-Tech-Consulting/Gitea-Tools as prgs-merger.
Pin expected head 5463f589334f6a9520e2512a3c33929976d924ac on feat/issue-798-worker-registry-schema targeting master.
Adopt or acquire merger lease; re-verify head; merge only if gates pass. Do not re-review.

WHAT_HAPPENED: Independent review of next eligible open PR #810 after skipping #794 and #795 (live REQUEST_CHANGES at unchanged heads).
WHY: Scope matches #798; validation passed; author-safety ok; not already landed.
ISSUE: #798
HEAD_SHA: 5463f58933
REVIEW_STATUS: APPROVED
MERGE_READY: true
BLOCKERS: none
VALIDATION: pytest tests/test_webui_worker_registry.py = 44 passed; pytest -k webui = 149 passed; worktree clean before/after
NATIVE_REVIEW_PROOF: transport=native_mcp; entrypoint=mcp_server; token_fingerprint=5ce4b79a999d0487
LAST_UPDATED_BY: sysadmin (prgs-reviewer)


Independent review detail

Verdict: APPROVE

Selected PR: #810 (next review-eligible after skip of blocked #794 and #795).
Author: jcwalker3 (not self-review).
Candidate/reviewed head: 5463f589334f6a9520e2512a3c33929976d924ac

Scope

Implements #798 only (worker registry schema). Parent epic #797 correctly stays open. Five files: new worker_registry, registry_safety extraction, empty workers array with five providers, tests, and project_registry credential-guard delegation.

Diff review

  • Provider vs worker separation, fail-closed unknown fields, referential integrity, unique scheduler labels.
  • Atomic write (temp+fsync+replace), append-only revision history with rollback as new head.
  • Credential key shape rejection shared with project_registry (behavior-preserving extract).
  • Packaged registry has empty workers by design (#808).
  • No HTTP surface; no secrets in registry document.

Validation

Worktree: /Users/jasonwalker/Development/Gitea-Tools/branches/review-pr-810-session @ 5463f58

  • Official: venv/bin/pytest tests/test_webui_worker_registry.py → 44 passed, 38 subtests
  • Official: venv/bin/pytest -k webui → 149 passed, 144 subtests
  • Clean before and after validation
  • Already-landed: no (head not ancestor of master 0b294040314e)

Merge decision

Approved for merger at pinned head only.

CTH: Reviewer Handoff

Status: approved_awaiting_merge
Next owner: merger (prgs-merger / sysadmin)
Current blocker: none
Decision: APPROVE PR #810 at head 5463f58933
Proof: validation 44+149 passed; author jcwalker3; not already landed; skips #794/#795 REQUEST_CHANGES at unchanged heads revalidated live
Next action: merger merges at exact pinned head if gates pass
Ready-to-paste prompt: see NEXT_PROMPT above

## Canonical PR State STATE: approved WHO_IS_NEXT: merger NEXT_ACTION: Merge PR #810 at pinned head 5463f589334f6a9520e2512a3c33929976d924ac if merger gates pass NEXT_PROMPT: ```text Merge PR #810 (Closes #798) on prgs Scaled-Tech-Consulting/Gitea-Tools as prgs-merger. Pin expected head 5463f589334f6a9520e2512a3c33929976d924ac on feat/issue-798-worker-registry-schema targeting master. Adopt or acquire merger lease; re-verify head; merge only if gates pass. Do not re-review. ``` WHAT_HAPPENED: Independent review of next eligible open PR #810 after skipping #794 and #795 (live REQUEST_CHANGES at unchanged heads). WHY: Scope matches #798; validation passed; author-safety ok; not already landed. ISSUE: #798 HEAD_SHA: 5463f589334f6a9520e2512a3c33929976d924ac REVIEW_STATUS: APPROVED MERGE_READY: true BLOCKERS: none VALIDATION: pytest tests/test_webui_worker_registry.py = 44 passed; pytest -k webui = 149 passed; worktree clean before/after NATIVE_REVIEW_PROOF: transport=native_mcp; entrypoint=mcp_server; token_fingerprint=5ce4b79a999d0487 LAST_UPDATED_BY: sysadmin (prgs-reviewer) --- ## Independent review detail ### Verdict: APPROVE **Selected PR:** #810 (next review-eligible after skip of blocked #794 and #795). **Author:** jcwalker3 (not self-review). **Candidate/reviewed head:** `5463f589334f6a9520e2512a3c33929976d924ac` ### Scope Implements #798 only (worker registry schema). Parent epic #797 correctly stays open. Five files: new worker_registry, registry_safety extraction, empty workers array with five providers, tests, and project_registry credential-guard delegation. ### Diff review - Provider vs worker separation, fail-closed unknown fields, referential integrity, unique scheduler labels. - Atomic write (temp+fsync+replace), append-only revision history with rollback as new head. - Credential key shape rejection shared with project_registry (behavior-preserving extract). - Packaged registry has empty workers by design (#808). - No HTTP surface; no secrets in registry document. ### Validation Worktree: `/Users/jasonwalker/Development/Gitea-Tools/branches/review-pr-810-session` @ `5463f58` - Official: `venv/bin/pytest tests/test_webui_worker_registry.py` → 44 passed, 38 subtests - Official: `venv/bin/pytest -k webui` → 149 passed, 144 subtests - Clean before and after validation - Already-landed: no (head not ancestor of master `0b294040314e`) ### Merge decision Approved for merger at pinned head only. <!-- cth:v1 --> ## CTH: Reviewer Handoff Status: approved_awaiting_merge Next owner: merger (prgs-merger / sysadmin) Current blocker: none Decision: APPROVE PR #810 at head 5463f589334f6a9520e2512a3c33929976d924ac Proof: validation 44+149 passed; author jcwalker3; not already landed; skips #794/#795 REQUEST_CHANGES at unchanged heads revalidated live Next action: merger merges at exact pinned head if gates pass Ready-to-paste prompt: see NEXT_PROMPT above
Owner

adopted_at: 2026-07-22T11:48:20Z
adopted_by_identity: sysadmin
adopted_by_profile: prgs-merger
adopted_from_session_id: 36068-f6679735fdeb
adopted_from_profile: prgs-reviewer
adopted_from_reviewer_identity: sysadmin
adopted_from_comment_id: 14146
adoption_reason: merger-handoff-approved-head

repo: Scaled-Tech-Consulting/Gitea-Tools
pr: #810
issue: #798
reviewer_identity: sysadmin
profile: prgs-merger
session_id: 36066-eda5ddb64f97
worktree: /Users/jasonwalker/Development/Gitea-Tools/branches/review-pr-810-session
phase: adopted
candidate_head: 5463f58933
target_branch: master
target_branch_sha: 0b29404031
last_activity: 2026-07-22T11:48:20Z
expires_at: 2026-07-22T11:58:20Z
blocker: none

<!-- mcp-review-lease-adoption:v1 --> adopted_at: 2026-07-22T11:48:20Z adopted_by_identity: sysadmin adopted_by_profile: prgs-merger adopted_from_session_id: 36068-f6679735fdeb adopted_from_profile: prgs-reviewer adopted_from_reviewer_identity: sysadmin adopted_from_comment_id: 14146 adoption_reason: merger-handoff-approved-head <!-- mcp-review-lease:v1 --> repo: Scaled-Tech-Consulting/Gitea-Tools pr: #810 issue: #798 reviewer_identity: sysadmin profile: prgs-merger session_id: 36066-eda5ddb64f97 worktree: /Users/jasonwalker/Development/Gitea-Tools/branches/review-pr-810-session phase: adopted candidate_head: 5463f589334f6a9520e2512a3c33929976d924ac target_branch: master target_branch_sha: 0b294040314ecf661dcd010cb55a05d923233a95 last_activity: 2026-07-22T11:48:20Z expires_at: 2026-07-22T11:58:20Z blocker: none
sysadmin merged commit 9eb0f29cef into master 2026-07-22 06:48:29 -05:00
Owner

Stale #332 review-decision lock cleanup (#594)

Status: APPLIED

Manual deletion of session-state files is not the workflow.
This path only clears a lock when the referenced PR is merged/closed.

## Stale #332 review-decision lock cleanup (#594) Status: **APPLIED** - actor: `sysadmin` - profile: `prgs-merger` - timestamp: `2026-07-22T11:48:31.335615+00:00` - last terminal: `approve` on PR #810 - PR state: `closed` (merged=True) - merge_commit_sha: `9eb0f29cefa85fd0ecbbb5f923ebc748bffa81f8` - prior live_mutations_count: `1` - prior profile_identity: `prgs-reviewer` Manual deletion of session-state files is **not** the workflow. This path only clears a lock when the referenced PR is merged/closed.
Sign in to join this conversation.
No Reviewers
No labels
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Scaled-Tech-Consulting/Gitea-Tools#810