diff --git a/CAPABILITIES.md b/CAPABILITIES.md index ab70f0a6..93a86d77 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -5,7 +5,7 @@ > before assuming something isn't built. `@api` = stable to build on; `@internal` = may change. > Grouped by **capability** (across layers), not by directory. -**229 classes** across **33 capabilities** · **25 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). +**244 classes** across **33 capabilities** · **26 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). ## Capabilities (`library/Tiger`) @@ -120,6 +120,8 @@ - **Tiger_License_Store_Option** `@api` — the default license store, backed by the lazy `option` tier. · `library/Tiger/License/Store/Option.php` - **Tiger_License_Vendor** `@api` — CONNECT + pin a paid module's vendor (its `[owner]/TigerVendor` repo). · `library/Tiger/License/Vendor.php` - **Tiger_Model_Module** `@api` — the module lifecycle registry (see migration 0023). · `library/Tiger/Model/Module.php` +- **Tiger_Model_ModuleInstallPin** `@api` — a consumer install's version policy + rollback anchor (TIGER-334, 0054). · `library/Tiger/Model/ModuleInstallPin.php` +- **Tiger_Model_ModuleRelease** `@api` — a module release moving through QA (TIGER-334, migration 0054). · `library/Tiger/Model/ModuleRelease.php` - **Tiger_Model_UpdateHistory** `@api` — the durable log of one-click update runs (table `update_history`). · `library/Tiger/Model/UpdateHistory.php` - **Tiger_Module_Compat** `@api` — advisory "which Tiger versions was this module tested for?" metadata. · `library/Tiger/Module/Compat.php` - **Tiger_Module_Dependency** `@api` — lightweight, lazy inter-module dependency alerts. · `library/Tiger/Module/Dependency.php` @@ -306,6 +308,9 @@ - **Tiger_Model_AgentRun** `@api` — one turn's execution + control record (see migration 0036). · `library/Tiger/Model/AgentRun.php` - **Tiger_Model_Comment** `@api` — the one comment/review store (migration 0045). · `library/Tiger/Model/Comment.php` - **Tiger_Model_CommentAggregate** `@api` — the denormalized per-subject rollup (migration 0046). · `library/Tiger/Model/CommentAggregate.php` +- **Tiger_Model_GitCredential** `@api` — a least-privilege, encrypted-at-rest credential (TIGER-334, migration 0054). · `library/Tiger/Model/GitCredential.php` +- **Tiger_Model_GitOperationLog** `@api` — the append-only git audit trail (TIGER-334, migration 0054). · `library/Tiger/Model/GitOperationLog.php` +- **Tiger_Model_GitRepoConnection** `@api` — a module's connection to its GitHub repo (TIGER-334, migration 0054). · `library/Tiger/Model/GitRepoConnection.php` - **Tiger_Model_Menu** `@api` — Menu — custom navigation menus (see migration 0017). · `library/Tiger/Model/Menu.php` - **Tiger_Model_Message** `@api` — the message body store (migration 0047, TIGER-114). · `library/Tiger/Model/Message.php` - **Tiger_Model_MessageBlock** `@api` — one user muting another, within an org (migration 0049). · `library/Tiger/Model/MessageBlock.php` @@ -319,6 +324,16 @@ - **Tiger_Comment** `@api` — the subject registry and policy gate for comments, ratings and reviews. · `library/Tiger/Comment.php` - **Tiger_Comment_Spam** `@api` — the spam-check registry, and the first checker: the in-platform AI agent. · `library/Tiger/Comment/Spam.php` +- **Tiger_Git** `@api` — the facade + shared constants for the platform's one git/GitHub layer (TIGER-334). · `library/Tiger/Git.php` +- **Tiger_Git_Credential** `@api` — the write-only credential manager (TIGER-334 / TIGER-365). · `library/Tiger/Git/Credential.php` +- **Tiger_Git_Environment** `@api` — detect which transport this instance can use (TIGER-334). · `library/Tiger/Git/Environment.php` +- **Tiger_Git_Exception** `@api` — a structured, agent-readable, SECRET-FREE git failure (TIGER-334). · `library/Tiger/Git/Exception.php` +- **Tiger_Git_Provider_GitHub** `@api` — the GitHub adapter: WHICH host, composing the two transports (TIGER-334). · `library/Tiger/Git/Provider/GitHub.php` +- **Tiger_Git_Provider_Interface** `@api` — WHICH host a connection talks to (TIGER-334). · `library/Tiger/Git/Provider/Interface.php` +- **Tiger_Git_Transport_Api** `@api` — git operations through the GitHub API, with NO `.git` directory (TIGER-364). · `library/Tiger/Git/Transport/Api.php` +- **Tiger_Git_Transport_Interface** `@api` — HOW a git operation is carried out (TIGER-334). · `library/Tiger/Git/Transport/Interface.php` +- **Tiger_Git_Transport_Shell** `@api` — git operations driven by the `git` binary (TIGER-364). · `library/Tiger/Git/Transport/Shell.php` +- **Tiger_Git_WorkingTree** `@api` — one module's git working tree (TIGER-334). · `library/Tiger/Git/WorkingTree.php` - **Tiger_Message** `@api` — the platform's way of telling people things (TIGER-114). · `library/Tiger/Message.php` - **Tiger_Secret** `@api` — provider-neutral resolution of secrets and sensitive config values. · `library/Tiger/Secret.php` - **Tiger_Secret_Adapter_Chain** `@api` — try several backends in order; the first hit wins. · `library/Tiger/Secret/Adapter/Chain.php` @@ -348,6 +363,7 @@ - **CMS** (`cms`, app) · services: Menu, Page, Paths, Settings · `modules/cms` - **Code** (`code`, developer) · services: Code · `modules/code` - **Comments** (`comment`, app) · services: Comment, Render, Settings, Subjects · `modules/comment` +- **TigerGit** (`git`, app) · services: Base, Release, Repo, Update · `modules/git` - **Identity** (`identity`, plugin) · services: Identity · `modules/identity` - **Matomo** (`matomo`, app) · services: Matomo · `modules/matomo` - **Mcp** (`mcp`, module) · services: Settings · `modules/mcp` diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b700e46..180874ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,33 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows ## [Unreleased] +## [1.38.0] — 2026-10-21 + +### Added +- **TigerGit (`modules/git` + `Tiger_Git_*`) — first-class git/GitHub for a Tiger install** (TIGER-334). + Version a custom module in a private repo, cut a release through QA, and receive approved updates — + with no local machine, no terminal, and (on shared hosting with `exec` disabled) no git binary. No + other module talks to GitHub directly. + - **Two interchangeable transports behind one interface** (`Tiger_Git_Transport_Interface`): a + `ShellTransport` (the `git` binary via `proc_open`, host-agnostic) and an `ApiTransport` that commits + through the GitHub Git Data API with **no `.git` directory** — blob → tree → commit → fast-forward + ref — the path that makes shared hosting work. `Tiger_Git_Environment` detects which the host can use. + - **`Tiger_Git_Provider_GitHub`** composes them: it picks the working transport per connection and + routes the provider-API operations (releases, archives) to the API even on a shell connection. The + provider/transport split (`Tiger_Git_Provider_Interface`) keeps the door open for other hosts. + - **The no-`.git` reconciler** — `status` / `diff` / `pull` / `switchBranch` over the API, using a + `.tiger-git/head` merge-base marker so an exec-disabled host can tell local edits from the remote + advancing; `pull` fast-forwards while preserving non-conflicting local work. + - **Write-only credentials** (`Tiger_Git_Credential`, encrypted at rest; a single `resolve()` read + path for transports), an **active-only-unique** data model so reconnect-after-disconnect never + collides, and an append-only operation log. + - **`/api` services** — `Git_Service_Repo` (connect / status / recheck / list / log / disconnect), + `Git_Service_Release` (tag / submit / decide / list; tag + submit are owner-only via the + `Tiger_Git::RESOURCE_RELEASE` capability, not a role compare), and `Git_Service_Update` (check / + apply / setPolicy / rollback — an update only ever installs an **approved** release at its exact + tag). An admin dashboard at `/git/admin`, and the whole surface is runnable over **MCP** (the git + tools reflect into `tools/list`; `git` is in the curated default token set). + ## [1.37.0] — 2026-10-21 ### Removed diff --git a/library/Tiger/Git.php b/library/Tiger/Git.php new file mode 100644 index 00000000..e7af63ff --- /dev/null +++ b/library/Tiger/Git.php @@ -0,0 +1,112 @@ +://…`; anything else is a local git_credential id. */ + private static function isSecretRef(string $ref): bool + { + return (bool) preg_match('~^[a-z][a-z0-9+.-]*://~', $ref); + } + + /** + * Store a new credential, encrypted. Returns the SAFE public view (NO secret). The caller then + * records the returned `ref` on the connection. + * + * @param string $orgId + * @param string $moduleKey + * @param string $type Tiger_Model_GitCredential::TYPE_TOKEN|TYPE_DEPLOY_KEY + * @param string $secret the plaintext token or private key (consumed, never returned) + * @param array $meta public_material?, fingerprint?, scopes? + * @return array{ref:string, public:array} + */ + public function store(string $orgId, string $moduleKey, string $type, string $secret, array $meta = []): array + { + if ($secret === '') { + throw new Tiger_Git_Exception(Tiger_Git_Exception::CREDENTIAL_MISSING, 'a credential value is required'); + } + if (!Tiger_Crypto::isConfigured()) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::CREDENTIAL_INVALID, 'encryption is not configured on this install'); + } + $model = new Tiger_Model_GitCredential(); + $id = (string) $model->insert([ + 'org_id' => $orgId, + 'module_key' => $moduleKey, + 'type' => $type, + 'ciphertext' => Tiger_Crypto::encrypt($secret), + 'public_material' => $meta['public_material'] ?? null, + 'fingerprint' => $meta['fingerprint'] ?? null, + 'scopes' => $meta['scopes'] ?? null, + 'status' => 'active', + ]); + sodium_memzero($secret); + $row = $model->findById($id); + return ['ref' => $id, 'public' => Tiger_Model_GitCredential::publicView($row)]; + } + + /** + * Resolve a credential reference to its PLAINTEXT secret, for a transport about to make a request. + * The ONLY read path. Returns null when the ref is unknown/revoked/unusable (the transport then + * marks the connection degraded). NEVER call this from a controller or log its result. + * + * @param string $ref a git_credential id or a Tiger_Secret ref + * @return string|null the plaintext secret, or null + * @internal for transports only + */ + public function resolve(string $ref): ?string + { + if ($ref === '') { return null; } + if (self::isSecretRef($ref)) { + $v = Tiger_Secret::get($ref); + return ($v === null || $v === '') ? null : $v; + } + $row = (new Tiger_Model_GitCredential())->findById($ref); + if (!$row || $row->revoked_at !== null) { return null; } + try { + return Tiger_Crypto::decrypt((string) $row->ciphertext); + } catch (\Throwable $e) { + return null; // unusable secret — treated as "not resolvable", never surfaced + } + } + + /** + * Rotate: store the new credential, let the caller VERIFY it works, then revoke the old one. The + * old secret is only revoked after the new one is recorded, so a rotation never leaves the owner + * with no working credential. Returns the new ref + public view; the caller verifies, then calls + * `revoke($oldRef)` once verification passes. + * + * @return array{ref:string, public:array} + */ + public function rotate(string $orgId, string $moduleKey, string $type, string $newSecret, array $meta = []): array + { + return $this->store($orgId, $moduleKey, $type, $newSecret, $meta); + } + + /** + * Revoke a local credential (mark revoked + soft-delete so the ciphertext stops resolving). A + * Tiger_Secret ref is not ours to revoke — the caller removes it at its authority. + * + * @return bool true if a local credential was revoked + */ + public function revoke(string $ref): bool + { + if ($ref === '' || self::isSecretRef($ref)) { return false; } + $model = new Tiger_Model_GitCredential(); + $row = $model->findById($ref); + if (!$row) { return false; } + $row->revoked_at = gmdate('Y-m-d H:i:s'); + $row->status = 'revoked'; + $row->save(); + $model->softDelete($model->getAdapter()->quoteInto('git_credential_id = ?', $ref)); + return true; + } + + /** + * Generate an Ed25519 deploy keypair (for the ShellTransport path). Returns the OpenSSH PUBLIC key + * line to show the owner (they paste it into the repo's deploy-key box) and its fingerprint; the + * returned `secret` is the private material to hand straight to `store()` and then forget. The + * private key is NEVER returned to any API surface. + * + * @param string $comment a label embedded in the public key (e.g. "tiger:my_module") + * @return array{public_openssh:string, fingerprint:string, secret:string} + */ + public function generateDeployKey(string $comment = 'tiger-git'): array + { + $pair = sodium_crypto_sign_keypair(); + $pub = sodium_crypto_sign_publickey($pair); // 32 bytes ed25519 public + $secret = sodium_crypto_sign_secretkey($pair); // 64 bytes (seed+public) + + // OpenSSH public-key wire format: string "ssh-ed25519" + string <32-byte key>, base64'd. + $blob = self::sshString('ssh-ed25519') . self::sshString($pub); + $publicLine = 'ssh-ed25519 ' . base64_encode($blob) . ' ' . $comment; + // Fingerprint is SHA256 of the blob, base64 (no padding) — the `SHA256:…` GitHub shows. + $fingerprint = 'SHA256:' . rtrim(base64_encode(hash('sha256', $blob, true)), '='); + + return [ + 'public_openssh' => $publicLine, + 'fingerprint' => $fingerprint, + 'secret' => base64_encode($secret), // stored encrypted; the shell path unwraps it + ]; + } + + /** SSH wire "string": 4-byte big-endian length prefix + bytes. */ + private static function sshString(string $s): string + { + return pack('N', strlen($s)) . $s; + } +} diff --git a/library/Tiger/Git/Environment.php b/library/Tiger/Git/Environment.php new file mode 100644 index 00000000..1fe05b74 --- /dev/null +++ b/library/Tiger/Git/Environment.php @@ -0,0 +1,120 @@ + $gitBinary, + 'exec_usable' => $execUsable, + 'outbound' => $outbound, + 'transport' => $transport, + 'usable' => $usable, + 'remediation' => $remediation, + 'git_version' => $gitVersion, + ]; + } + + /** Are proc_open AND exec-family calls actually callable here (not disabled)? */ + public static function execUsable(): bool + { + if (!function_exists('proc_open')) { return false; } + $disabled = array_filter(array_map('trim', explode(',', (string) ini_get('disable_functions')))); + return !in_array('proc_open', $disabled, true); + } + + /** `git --version` string when the binary runs, else null. */ + public static function gitVersion(): ?string + { + if (!self::execUsable()) { return null; } + $descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; + $proc = @proc_open(['git', '--version'], $descriptors, $pipes); + if (!is_resource($proc)) { return null; } + $out = stream_get_contents($pipes[1]) ?: ''; + foreach ($pipes as $p) { if (is_resource($p)) { fclose($p); } } + $exit = proc_close($proc); + if ($exit !== 0) { return null; } + return preg_match('/\d+\.\d+(\.\d+)?/', $out, $m) ? $m[0] : trim($out); + } + + /** Is $host reachable over HTTPS right now? Behind the test seam; the real path is a cheap cURL HEAD. */ + public static function outboundReachable(string $host): bool + { + if (!Tiger_Git::isAllowedHost($host)) { return false; } + if (self::$probe !== null) { + try { return (bool) (self::$probe)($host); } catch (\Throwable $e) { return false; } + } + if (!function_exists('curl_init')) { return false; } + $ch = curl_init('https://' . $host); + curl_setopt_array($ch, [ + CURLOPT_NOBODY => true, + CURLOPT_TIMEOUT => 6, + CURLOPT_CONNECTTIMEOUT => 4, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_USERAGENT => 'Tiger-Git', + ]); + curl_exec($ch); + $errno = curl_errno($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + return $errno === 0 && $code > 0; + } +} diff --git a/library/Tiger/Git/Exception.php b/library/Tiger/Git/Exception.php new file mode 100644 index 00000000..4d8dcf08 --- /dev/null +++ b/library/Tiger/Git/Exception.php @@ -0,0 +1,65 @@ + safe extra context (no secrets) */ + protected $detail; + + /** + * @param string $code one of the class constants + * @param string $message a short, secret-free human sentence + * @param array $detail safe structured context (conflicting files, http status, …) + */ + public function __construct(string $code, string $message = '', array $detail = []) + { + parent::__construct($message !== '' ? $message : $code); + $this->gitCode = $code; + $this->detail = $detail; + } + + /** The stable error code (Exception::getCode() is final, so this is the accessor). */ + public function code(): string + { + return $this->gitCode; + } + + /** Safe structured context. @return array */ + public function getDetail(): array + { + return $this->detail; + } +} diff --git a/library/Tiger/Git/Provider/GitHub.php b/library/Tiger/Git/Provider/GitHub.php new file mode 100644 index 00000000..a5259722 --- /dev/null +++ b/library/Tiger/Git/Provider/GitHub.php @@ -0,0 +1,148 @@ +api = new Tiger_Git_Transport_Api($credentials, $http); + $this->shell = new Tiger_Git_Transport_Shell($credentials, $runner); + } + + /** The provider/host key. */ + public function provider(): string + { + return self::PROVIDER; + } + + /** + * The working transport recommended for THIS host (detected once, memoized) — not a specific + * connection. A connection stores its own chosen transport (set at connect, re-checkable); this is + * the host-capability answer, used for display and as the fallback when a tree names no transport. + */ + public function name(): string + { + if ($this->hostTransport === null) { + $env = Tiger_Git_Environment::detect(); + $this->hostTransport = $env['transport'] !== '' ? $env['transport'] : Tiger_Git::TRANSPORT_API; + } + return $this->hostTransport; + } + + // --- git operations → the connection's working transport ------------------------------------- + + public function status(Tiger_Git_WorkingTree $tree): array + { + return $this->working($tree)->status($tree); + } + + public function diff(Tiger_Git_WorkingTree $tree, array $opts = []): array + { + return $this->working($tree)->diff($tree, $opts); + } + + public function commit(Tiger_Git_WorkingTree $tree, string $message, array $paths = []): string + { + return $this->working($tree)->commit($tree, $message, $paths); + } + + public function branches(Tiger_Git_WorkingTree $tree): array + { + return $this->working($tree)->branches($tree); + } + + public function createBranch(Tiger_Git_WorkingTree $tree, string $name): void + { + $this->working($tree)->createBranch($tree, $name); + } + + public function switchBranch(Tiger_Git_WorkingTree $tree, string $name): void + { + $this->working($tree)->switchBranch($tree, $name); + } + + public function push(Tiger_Git_WorkingTree $tree, string $branch): array + { + return $this->working($tree)->push($tree, $branch); + } + + public function pull(Tiger_Git_WorkingTree $tree, string $branch): array + { + return $this->working($tree)->pull($tree, $branch); + } + + public function tag(Tiger_Git_WorkingTree $tree, string $tag, ?string $ref = null): array + { + return $this->working($tree)->tag($tree, $tag, $ref); + } + + // --- provider-API operations → ALWAYS the API transport -------------------------------------- + + public function listReleases(Tiger_Git_WorkingTree $tree): array + { + return $this->api->listReleases($tree); + } + + public function fetchArchive(Tiger_Git_WorkingTree $tree, string $tag, string $destFile): string + { + return $this->api->fetchArchive($tree, $tag, $destFile); + } + + // --- internals ------------------------------------------------------------------------------- + + /** + * The working transport for a connection: the git binary when the connection chose shell AND the + * host can still run a subprocess, the API otherwise. A tree that names no transport falls back to + * host detection; a stale "shell" choice on a host that lost `exec` degrades to the API rather than + * failing every operation. + */ + private function working(Tiger_Git_WorkingTree $tree): Tiger_Git_Transport_Interface + { + $want = $tree->transport(); + if ($want === '') { $want = $this->name(); } + if ($want === Tiger_Git::TRANSPORT_SHELL && Tiger_Git_Environment::execUsable()) { + return $this->shell; + } + return $this->api; + } +} diff --git a/library/Tiger/Git/Provider/Interface.php b/library/Tiger/Git/Provider/Interface.php new file mode 100644 index 00000000..bc4efeb8 --- /dev/null +++ b/library/Tiger/Git/Provider/Interface.php @@ -0,0 +1,25 @@ + memoized token per credential ref (one resolve per request) */ + private array $tokenCache = []; + + public function __construct(Tiger_Git_Credential $credentials, ?callable $http = null) + { + $this->credentials = $credentials; + $this->http = $http; + } + + /** Swap the HTTP transport (tests inject canned GitHub responses). Null restores real cURL. */ + public function setHttp(?callable $http): void + { + $this->http = $http; + } + + public function name(): string + { + return Tiger_Git::TRANSPORT_API; + } + + // --- the spike: create a commit with no local repo ------------------------------------------- + + public function commit(Tiger_Git_WorkingTree $tree, string $message, array $paths = []): string + { + if (trim($message) === '') { + throw new Tiger_Git_Exception(Tiger_Git_Exception::INVALID_REF, 'a commit message is required'); + } + if ($paths === []) { + // No local index to infer "all changes" from — the caller (the agent/Forge) names the files. + throw new Tiger_Git_Exception( + Tiger_Git_Exception::NOTHING_TO_COMMIT, + 'the API transport commits explicit paths (it has no local index to diff against)' + ); + } + $branch = $tree->currentBranch(); + $repo = $this->repoPath($tree); + + // 1. the branch head + its tree. + $ref = $this->api($tree, 'GET', $repo . '/git/ref/heads/' . $branch); + $headSha = (string) ($ref['object']['sha'] ?? ''); + if ($headSha === '') { + throw new Tiger_Git_Exception(Tiger_Git_Exception::INVALID_REF, 'could not resolve the branch head'); + } + $headCommit = $this->api($tree, 'GET', $repo . '/git/commits/' . $headSha); + $baseTree = (string) ($headCommit['tree']['sha'] ?? ''); + + // 2. one tree entry per path — a blob for an add/modify, a null sha for a delete. + $entries = []; + foreach ($paths as $rel) { + $rel = ltrim(str_replace('\\', '/', (string) $rel), '/'); + if ($rel === '') { continue; } + $abs = $tree->resolve($rel); // refuses a path that escapes the module dir + if (is_file($abs)) { + $blob = $this->api($tree, 'POST', $repo . '/git/blobs', [ + 'content' => base64_encode((string) file_get_contents($abs)), + 'encoding' => 'base64', + ]); + $entries[] = [ + 'path' => $rel, + 'mode' => is_executable($abs) ? '100755' : '100644', + 'type' => 'blob', + 'sha' => (string) ($blob['sha'] ?? ''), + ]; + } else { + // Not on disk → stage a deletion (GitHub removes it when base_tree is set). + $entries[] = ['path' => $rel, 'mode' => '100644', 'type' => 'blob', 'sha' => null]; + } + } + if ($entries === []) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::NOTHING_TO_COMMIT, 'no files resolved to commit'); + } + + // 3. new tree → new commit (parent = the old head). + $newTree = $this->api($tree, 'POST', $repo . '/git/trees', ['base_tree' => $baseTree, 'tree' => $entries]); + $commit = $this->api($tree, 'POST', $repo . '/git/commits', [ + 'message' => $message, + 'tree' => (string) ($newTree['sha'] ?? ''), + 'parents' => [$headSha], + ]); + $newSha = (string) ($commit['sha'] ?? ''); + if ($newSha === '') { + throw new Tiger_Git_Exception(Tiger_Git_Exception::API_ERROR, 'commit creation returned no sha'); + } + + // 4. fast-forward the branch to the new commit (force:false → NON_FAST_FORWARD if it moved). + $this->api($tree, 'PATCH', $repo . '/git/refs/heads/' . $branch, ['sha' => $newSha, 'force' => false]); + + // The working tree is now synced to this commit — record the merge base for status/diff/pull. + $this->_setBaseSha($tree, $newSha); + return $newSha; + } + + /** + * With the API transport, `commit()` already wrote to the remote branch — there is no separate local + * repo to push from. So push is a verified no-op: it confirms the branch head and reports it. + */ + public function push(Tiger_Git_WorkingTree $tree, string $branch): array + { + $ref = $this->api($tree, 'GET', $this->repoPath($tree) . '/git/ref/heads/' . $branch); + return ['ref' => (string) ($ref['object']['sha'] ?? ''), 'pushed' => true]; + } + + public function branches(Tiger_Git_WorkingTree $tree): array + { + $list = $this->api($tree, 'GET', $this->repoPath($tree) . '/branches?per_page=100'); + return array_values(array_map(static fn ($b) => (string) ($b['name'] ?? ''), is_array($list) ? $list : [])); + } + + public function createBranch(Tiger_Git_WorkingTree $tree, string $name): void + { + if ($name === $tree->defaultBranch()) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::PROTECTED_BRANCH, 'refusing to recreate the default branch'); + } + $repo = $this->repoPath($tree); + $ref = $this->api($tree, 'GET', $repo . '/git/ref/heads/' . $tree->currentBranch()); + $sha = (string) ($ref['object']['sha'] ?? ''); + if ($sha === '') { + throw new Tiger_Git_Exception(Tiger_Git_Exception::INVALID_REF, 'could not resolve the source branch head'); + } + $this->api($tree, 'POST', $repo . '/git/refs', ['ref' => 'refs/heads/' . $name, 'sha' => $sha]); + } + + // --- provider-API operations (releases + archives — the GitHub adapter routes these here) ---- + + /** + * The repository's releases, newest first (drafts excluded — a draft has no installable tag). The + * `sha` is the release's `target_commitish` (a branch name or a commit sha — what the releases + * endpoint gives without an extra call per release). + */ + public function listReleases(Tiger_Git_WorkingTree $tree): array + { + $list = $this->api($tree, 'GET', $this->repoPath($tree) . '/releases?per_page=100'); + $out = []; + foreach (is_array($list) ? $list : [] as $r) { + if (!is_array($r) || !empty($r['draft'])) { continue; } + $tag = (string) ($r['tag_name'] ?? ''); + $name = (string) ($r['name'] ?? ''); + $out[] = [ + 'tag' => $tag, + 'name' => $name !== '' ? $name : $tag, + 'sha' => (string) ($r['target_commitish'] ?? ''), + 'created_at' => (string) ($r['created_at'] ?? ''), + 'prerelease' => (bool) ($r['prerelease'] ?? false), + ]; + } + return $out; + } + + /** + * Download a tag's source archive (`.tar.gz`) to $destFile. GitHub answers the tarball endpoint with + * a 302 to codeload.github.com; we capture the redirect, confirm it stays on the host allowlist, and + * stream the bytes from the CDN WITHOUT re-sending our credential (the signed URL carries its own). + * This is the one artifact an update ever installs — a tag's tree, never an arbitrary branch. + */ + public function fetchArchive(Tiger_Git_WorkingTree $tree, string $tag, string $destFile): string + { + $this->assertTag($tag); + if (!Tiger_Git::isAllowedHost(self::HOST)) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::HOST_NOT_ALLOWED, 'api.github.com is not allowed'); + } + + // Ask GitHub for the tarball; it answers a 302 to the download host (codeload, on the allowlist). + $res = $this->send('GET', self::API . $this->repoPath($tree) . '/tarball/' . $tag, $this->authHeaders($tree), null); + $status = (int) ($res['status'] ?? 0); + + if (in_array($status, [301, 302, 303, 307, 308], true)) { + $loc = $this->header($res, 'location'); + if ($loc === '') { + throw new Tiger_Git_Exception(Tiger_Git_Exception::API_ERROR, 'the archive redirect carried no location'); + } + $host = strtolower((string) parse_url($loc, PHP_URL_HOST)); + if (!Tiger_Git::isAllowedHost($host)) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::HOST_NOT_ALLOWED, 'the archive redirect left the allowed hosts'); + } + // The signed CDN URL authenticates itself — never forward our token to it. + $dl = $this->send('GET', $loc, ['User-Agent' => 'Tiger-Git'], null); + $dstatus = (int) ($dl['status'] ?? 0); + if ($dstatus < 200 || $dstatus >= 300) { + throw $this->mapError($dstatus, [], (string) ($dl['body'] ?? '')); + } + $bytes = (string) ($dl['body'] ?? ''); + } elseif ($status >= 200 && $status < 300) { + $bytes = (string) ($res['body'] ?? ''); // some setups return the bytes directly + } else { + $decoded = json_decode((string) ($res['body'] ?? ''), true); + throw $this->mapError($status, is_array($decoded) ? $decoded : [], (string) ($res['body'] ?? '')); + } + + if (@file_put_contents($destFile, $bytes) === false) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::API_ERROR, 'could not write the release archive'); + } + return $destFile; + } + + /** + * Create an annotated tag over the API and point a tag ref at it — no local repo. The target is the + * commit resolved from $ref (a branch, tag or sha) or, by default, the current branch head. Mirrors + * the shell transport's annotated-tag semantics; the returned `sha` is the tagged COMMIT (not the + * tag object), so both transports agree. + */ + public function tag(Tiger_Git_WorkingTree $tree, string $tag, ?string $ref = null): array + { + $this->assertTag($tag); + $repo = $this->repoPath($tree); + + if ($ref !== null && $ref !== '') { + $this->assertTag($ref); + $commit = $this->api($tree, 'GET', $repo . '/commits/' . $ref); // resolves a branch/tag/sha → a commit + $sha = (string) ($commit['sha'] ?? ''); + } else { + $head = $this->api($tree, 'GET', $repo . '/git/ref/heads/' . $tree->currentBranch()); + $sha = (string) ($head['object']['sha'] ?? ''); + } + if ($sha === '') { + throw new Tiger_Git_Exception(Tiger_Git_Exception::INVALID_REF, 'could not resolve the commit to tag'); + } + + // The annotated tag object, then the ref that points at it (lightweight if the object step is skipped). + $obj = $this->api($tree, 'POST', $repo . '/git/tags', ['tag' => $tag, 'message' => $tag, 'object' => $sha, 'type' => 'commit']); + $tagSha = (string) ($obj['sha'] ?? ''); + $this->api($tree, 'POST', $repo . '/git/refs', ['ref' => 'refs/tags/' . $tag, 'sha' => $tagSha !== '' ? $tagSha : $sha]); + + return ['tag' => $tag, 'sha' => $sha]; + } + + // --- the reconciler: working-tree ↔ remote-tree, with no local .git ------------------------- + + /** + * Working-tree status without a `.git`: the on-disk files compared to the last-synced commit (the + * `.tiger-git/head` MARKER — the merge base this transport keeps so it can tell MY uncommitted edits + * from the remote simply moving on). `changed` is disk vs that base (A/M/D); `behind` is how many + * commits the remote branch head is ahead of the base (GitHub's compare API); `ahead` is always 0 — + * the API transport has no local-only commits (a commit goes straight to the remote). + */ + public function status(Tiger_Git_WorkingTree $tree): array + { + $branch = $tree->currentBranch(); + $head = $this->_headSha($tree, $branch); + $base = $this->_baseSha($tree); + + $baseTree = []; + $behind = 0; + try { + $baseTree = $this->_treeOf($tree, $base ?? $head); + if ($base !== null && $base !== $head) { + $cmp = $this->_compare($tree, $base, $head); // base...head → head ahead_by N + $behind = (int) ($cmp['ahead_by'] ?? 0); + } + } catch (Tiger_Git_Exception $e) { + // A stale/garbage-collected base must never break status — fall back to the head tree. + $baseTree = $this->_treeOf($tree, $head); + } + + $changed = $this->_workingChanges($tree, $baseTree); + return ['branch' => $branch, 'ahead' => 0, 'behind' => $behind, 'changed' => $changed, 'clean' => $changed === []]; + } + + /** + * A size-capped diff summary of the working tree against the base (the committed baseline). Added + * lines / removed lines per changed file via a line LCS (bounded); binary files are flagged, never + * counted. `opts.paths` restricts the set; `opts.max_bytes`, when > 0, caps the number of files + * reported (mirroring the shell transport's cap) and sets `truncated`. + */ + public function diff(Tiger_Git_WorkingTree $tree, array $opts = []): array + { + $base = $this->_baseSha($tree) ?? $this->_headSha($tree, $tree->currentBranch()); + $baseTree = $this->_treeOf($tree, $base); + $only = (!empty($opts['paths']) && is_array($opts['paths'])) ? array_flip(array_map('strval', $opts['paths'])) : null; + $cap = (int) ($opts['max_bytes'] ?? 0) > 0 ? (int) $opts['max_bytes'] : self::MAX_FILES; + + $files = []; + $truncated = false; + foreach ($this->_workingChanges($tree, $baseTree) as $c) { + if ($only !== null && !isset($only[$c['path']])) { continue; } + if (count($files) >= $cap) { $truncated = true; break; } + $files[] = $this->_fileDiff($tree, $baseTree, $c); + } + return ['files' => $files, 'truncated' => $truncated]; + } + + /** + * Switch the working tree to an existing branch by MATERIALISING that branch's tree to disk — the + * no-`.git` equivalent of a checkout. Refuses a dirty tree (uncommitted edits would be lost) and a + * missing branch (INVALID_REF). The caller re-points the connection's current branch (the tree is + * immutable); this does the on-disk checkout and resets the base marker to the new head. + */ + public function switchBranch(Tiger_Git_WorkingTree $tree, string $name): void + { + $target = $this->_headSha($tree, $name); // 404 → INVALID_REF + $base = $this->_baseSha($tree) ?? $this->_headSha($tree, $tree->currentBranch()); + $baseTree = $this->_treeOf($tree, $base); + if ($this->_workingChanges($tree, $baseTree) !== []) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::DIRTY_TREE, 'refusing to switch with uncommitted changes'); + } + $targetTree = $this->_treeOf($tree, $target); + $apply = []; + foreach ($targetTree as $path => $meta) { $apply[$path] = $meta['sha']; } + foreach ($baseTree as $path => $meta) { if (!isset($targetTree[$path])) { $apply[$path] = null; } } + $this->_materialize($tree, $apply); + $this->_setBaseSha($tree, $target); + } + + /** + * Fast-forward the working tree to the remote branch head. No local commit graph, so "FF" means: + * bring disk up to the files that changed between the base and the head, PRESERVING non-conflicting + * local edits. A file edited BOTH locally and remotely is a MERGE_CONFLICT — the tree is left + * untouched and the conflicting paths are reported (detail). Already-current is a clean no-op. + */ + public function pull(Tiger_Git_WorkingTree $tree, string $branch): array + { + $head = $this->_headSha($tree, $branch); + $base = $this->_baseSha($tree); + if ($base === null || $base === $head) { + if ($base === null) { $this->_setBaseSha($tree, $head); } // first sync: adopt the head as the base + return ['ref' => $head, 'updated' => false, 'conflicts' => []]; + } + + $cmp = $this->_compare($tree, $base, $head); + $remote = []; + foreach (($cmp['files'] ?? []) as $f) { $remote[(string) $f['filename']] = (string) ($f['status'] ?? 'modified'); } + if ($remote === []) { $this->_setBaseSha($tree, $head); return ['ref' => $head, 'updated' => false, 'conflicts' => []]; } + + $local = []; + foreach ($this->_workingChanges($tree, $this->_treeOf($tree, $base)) as $c) { $local[$c['path']] = true; } + $conflicts = array_values(array_intersect(array_keys($remote), array_keys($local))); + if ($conflicts !== []) { + sort($conflicts); + throw new Tiger_Git_Exception(Tiger_Git_Exception::MERGE_CONFLICT, 'local changes conflict with incoming changes', ['files' => $conflicts]); + } + + $headTree = $this->_treeOf($tree, $head); + $apply = []; + foreach ($remote as $path => $st) { + $apply[$path] = ($st === 'removed') ? null : ($headTree[$path]['sha'] ?? null); + } + $this->_materialize($tree, $apply); + $this->_setBaseSha($tree, $head); + return ['ref' => $head, 'updated' => true, 'conflicts' => []]; + } + + // --- internals: reconciler helpers ----------------------------------------------------------- + + /** Directories never walked as part of the working tree (noise + our own marker). */ + private const SKIP = ['.git', '.tiger-git', 'node_modules', 'vendor', '.DS_Store']; + /** Hard caps so a pathological tree can't hang the host. */ + private const MAX_FILES = 2000; + private const DIFF_CELL_CAP = 4000000; + + /** Resolve a branch head to its commit sha (404 → INVALID_REF). */ + private function _headSha(Tiger_Git_WorkingTree $tree, string $branch): string + { + $this->assertTag($branch); + $ref = $this->api($tree, 'GET', $this->repoPath($tree) . '/git/ref/heads/' . $branch); + $sha = (string) ($ref['object']['sha'] ?? ''); + if ($sha === '') { throw new Tiger_Git_Exception(Tiger_Git_Exception::INVALID_REF, 'could not resolve the branch head'); } + return $sha; + } + + /** A commit's full (recursive) blob tree: path => {sha, mode}. */ + private function _treeOf(Tiger_Git_WorkingTree $tree, string $commitSha): array + { + $commit = $this->api($tree, 'GET', $this->repoPath($tree) . '/git/commits/' . $commitSha); + $treeSha = (string) ($commit['tree']['sha'] ?? ''); + if ($treeSha === '') { return []; } + $data = $this->api($tree, 'GET', $this->repoPath($tree) . '/git/trees/' . $treeSha . '?recursive=1'); + $map = []; + foreach (($data['tree'] ?? []) as $e) { + if (($e['type'] ?? '') === 'blob') { + $map[(string) $e['path']] = ['sha' => (string) ($e['sha'] ?? ''), 'mode' => (string) ($e['mode'] ?? '100644')]; + } + } + return $map; + } + + /** GitHub's two-dot-three compare: base...head → {ahead_by, behind_by, files:[{filename,status}]}. */ + private function _compare(Tiger_Git_WorkingTree $tree, string $base, string $head): array + { + return $this->api($tree, 'GET', $this->repoPath($tree) . '/compare/' . $base . '...' . $head); + } + + /** One blob's decoded content. */ + private function _fetchBlob(Tiger_Git_WorkingTree $tree, string $sha): string + { + $b = $this->api($tree, 'GET', $this->repoPath($tree) . '/git/blobs/' . $sha); + $enc = (string) ($b['encoding'] ?? 'base64'); + $content = (string) ($b['content'] ?? ''); + return $enc === 'base64' ? (string) base64_decode($content) : $content; + } + + /** Write (or, with a null sha, delete) each path — every target resolved under the tree root. */ + private function _materialize(Tiger_Git_WorkingTree $tree, array $paths): void + { + foreach ($paths as $rel => $sha) { + $abs = $tree->resolve((string) $rel); // PATH_ESCAPE guard + if ($sha === null) { if (is_file($abs)) { @unlink($abs); } continue; } + $dir = dirname($abs); + if (!is_dir($dir)) { @mkdir($dir, 0775, true); } + file_put_contents($abs, $this->_fetchBlob($tree, (string) $sha)); + } + } + + /** On-disk files (relative => absolute), skipping the SKIP dirs, capped at MAX_FILES. */ + private function _walkDisk(Tiger_Git_WorkingTree $tree): array + { + $out = []; + $this->_walk($tree->dir(), '', $out); + return $out; + } + + private function _walk(string $dir, string $prefix, array &$out): void + { + foreach ((scandir($dir) ?: []) as $e) { + if ($e === '.' || $e === '..' || in_array($e, self::SKIP, true)) { continue; } + $abs = $dir . '/' . $e; + $rel = $prefix === '' ? $e : $prefix . '/' . $e; + if (is_dir($abs)) { + if (count($out) < self::MAX_FILES) { $this->_walk($abs, $rel, $out); } + } elseif (is_file($abs) && count($out) < self::MAX_FILES) { + $out[$rel] = $abs; + } + } + } + + /** The git blob sha of some content (sha1 of "blob \0") — matches GitHub's object id. */ + private function _blobSha(string $content): string + { + return sha1('blob ' . strlen($content) . "\0" . $content); + } + + /** + * The working-tree changes vs a base tree: A (on disk, not tracked), M (tracked, content differs), + * D (tracked, gone from disk). Sorted by path. + * + * @return array + */ + private function _workingChanges(Tiger_Git_WorkingTree $tree, array $baseTree): array + { + $disk = $this->_walkDisk($tree); + $changed = []; + foreach ($baseTree as $path => $meta) { + if (!isset($disk[$path])) { $changed[] = ['path' => $path, 'state' => 'D']; continue; } + if ($this->_blobSha((string) file_get_contents($disk[$path])) !== $meta['sha']) { + $changed[] = ['path' => $path, 'state' => 'M']; + } + unset($disk[$path]); + } + foreach ($disk as $path => $abs) { $changed[] = ['path' => $path, 'state' => 'A']; } + usort($changed, static fn ($a, $b) => strcmp($a['path'], $b['path'])); + return $changed; + } + + /** Added/removed/binary for one changed file (fetches the base blob for a modify/delete). */ + private function _fileDiff(Tiger_Git_WorkingTree $tree, array $baseTree, array $c): array + { + $path = $c['path']; + if ($c['state'] === 'D') { + $old = isset($baseTree[$path]) ? $this->_fetchBlob($tree, $baseTree[$path]['sha']) : ''; + return $this->_isBinary($old) + ? ['path' => $path, 'added' => 0, 'removed' => 0, 'binary' => true] + : ['path' => $path, 'added' => 0, 'removed' => $this->_lineCount($old), 'binary' => false]; + } + $new = (string) file_get_contents($tree->resolve($path)); + if ($c['state'] === 'A') { + return $this->_isBinary($new) + ? ['path' => $path, 'added' => 0, 'removed' => 0, 'binary' => true] + : ['path' => $path, 'added' => $this->_lineCount($new), 'removed' => 0, 'binary' => false]; + } + $old = isset($baseTree[$path]) ? $this->_fetchBlob($tree, $baseTree[$path]['sha']) : ''; + if ($this->_isBinary($old) || $this->_isBinary($new)) { + return ['path' => $path, 'added' => 0, 'removed' => 0, 'binary' => true]; + } + [$added, $removed] = $this->_lineDiff($old, $new); + return ['path' => $path, 'added' => $added, 'removed' => $removed, 'binary' => false]; + } + + private function _isBinary(string $s): bool + { + return strpos($s, "\0") !== false; + } + + /** Content → line array (a trailing newline does not count as an extra empty line). */ + private function _lines(string $s): array + { + if ($s === '') { return []; } + $lines = explode("\n", $s); + if (end($lines) === '') { array_pop($lines); } + return $lines; + } + + private function _lineCount(string $s): int + { + return count($this->_lines($s)); + } + + /** + * Added/removed line counts via a bounded line-LCS (rolling two-row, O(n·m) time, O(m) memory). + * Over DIFF_CELL_CAP cells it falls back to the net delta — a summary, never exact for huge files. + * + * @return array{0:int,1:int} [added, removed] + */ + private function _lineDiff(string $old, string $new): array + { + $a = $this->_lines($old); + $b = $this->_lines($new); + $n = count($a); + $m = count($b); + if ($n === 0) { return [$m, 0]; } + if ($m === 0) { return [0, $n]; } + if ($n * $m > self::DIFF_CELL_CAP) { return [max(0, $m - $n), max(0, $n - $m)]; } + + $prev = array_fill(0, $m + 1, 0); + for ($i = 1; $i <= $n; $i++) { + $cur = array_fill(0, $m + 1, 0); + $ai = $a[$i - 1]; + for ($j = 1; $j <= $m; $j++) { + $cur[$j] = ($ai === $b[$j - 1]) ? $prev[$j - 1] + 1 : max($prev[$j], $cur[$j - 1]); + } + $prev = $cur; + } + $lcs = $prev[$m]; + return [$m - $lcs, $n - $lcs]; + } + + /** The merge-base marker file (`.tiger-git/head`) — the last commit this working tree was synced to. */ + private function _baseSha(Tiger_Git_WorkingTree $tree): ?string + { + $f = $tree->dir() . '/.tiger-git/head'; + if (!is_file($f)) { return null; } + $s = trim((string) file_get_contents($f)); + return preg_match('/^[0-9a-f]{40}$/', $s) ? $s : null; + } + + /** Record the synced commit sha in the marker (so status/pull can tell local edits from a moved remote). */ + private function _setBaseSha(Tiger_Git_WorkingTree $tree, string $sha): void + { + if (!preg_match('/^[0-9a-f]{40}$/', $sha)) { return; } + $dir = $tree->dir() . '/.tiger-git'; + if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) { return; } + @file_put_contents($dir . '/head', $sha . "\n"); + } + + // --- internals: HTTP + helpers --------------------------------------------------------------- + + /** A tag name GitHub will accept in a URL path and that carries no traversal/option trickery. */ + private function assertTag(string $tag): void + { + if ($tag === '' || $tag[0] === '-' || !preg_match('~^[A-Za-z0-9._/-]+$~', $tag) || str_contains($tag, '..')) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::INVALID_REF, 'invalid tag name'); + } + } + + /** Case-insensitive lookup of a response header from a send() result. */ + private function header(array $res, string $name): string + { + $name = strtolower($name); + foreach (($res['headers'] ?? []) as $k => $v) { + if (strtolower((string) $k) === $name) { + return is_array($v) ? (string) ($v[0] ?? '') : (string) $v; + } + } + return ''; + } + + /** The base authenticated GitHub headers (no Content-Type — api() adds it when there's a body). */ + private function authHeaders(Tiger_Git_WorkingTree $tree): array + { + return [ + 'Authorization' => 'Bearer ' . $this->token($tree), + 'Accept' => 'application/vnd.github+json', + 'X-GitHub-Api-Version' => '2022-11-28', + 'User-Agent' => 'Tiger-Git', + ]; + } + + private function repoPath(Tiger_Git_WorkingTree $tree): string + { + return '/repos/' . $tree->owner() . '/' . $tree->repo(); + } + + /** The token for a tree's credential, resolved once per request. Throws when a write has none. */ + private function token(Tiger_Git_WorkingTree $tree): string + { + $refKey = $tree->credentialRef(); + if (!array_key_exists($refKey, $this->tokenCache)) { + $this->tokenCache[$refKey] = $this->credentials->resolve($refKey); + } + $tok = $this->tokenCache[$refKey]; + if ($tok === null || $tok === '') { + throw new Tiger_Git_Exception(Tiger_Git_Exception::CREDENTIAL_MISSING, 'no usable credential for this connection'); + } + return $tok; + } + + /** + * One authenticated GitHub API call. Returns the decoded JSON body (array), or throws a structured + * Tiger_Git_Exception mapping the status to a stable code. Pinned to api.github.com. + * + * @param array|null $json a request body to JSON-encode, or null for none + * @return array + */ + private function api(Tiger_Git_WorkingTree $tree, string $method, string $path, ?array $json = null): array + { + if (!Tiger_Git::isAllowedHost(self::HOST)) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::HOST_NOT_ALLOWED, 'api.github.com is not allowed'); + } + $headers = $this->authHeaders($tree); + $body = $json !== null ? json_encode($json) : null; + if ($body !== null) { $headers['Content-Type'] = 'application/json'; } + + $res = $this->send($method, self::API . $path, $headers, $body); + $status = (int) ($res['status'] ?? 0); + $decoded = json_decode((string) ($res['body'] ?? ''), true); + $decoded = is_array($decoded) ? $decoded : []; + + if ($status >= 200 && $status < 300) { + return $decoded; + } + throw $this->mapError($status, $decoded, (string) ($res['body'] ?? '')); + } + + /** Translate a non-2xx GitHub response to a stable, secret-free error code. */ + private function mapError(int $status, array $decoded, string $raw): Tiger_Git_Exception + { + $msg = (string) ($decoded['message'] ?? ''); + $detail = ['status' => $status]; + + if ($status === 401) { + return new Tiger_Git_Exception(Tiger_Git_Exception::CREDENTIAL_INVALID, 'the credential was rejected by GitHub', $detail); + } + if ($status === 403) { + if (stripos($msg, 'rate limit') !== false || stripos($raw, 'rate limit') !== false) { + return new Tiger_Git_Exception(Tiger_Git_Exception::RATE_LIMITED, 'GitHub rate limit reached', $detail); + } + return new Tiger_Git_Exception(Tiger_Git_Exception::CREDENTIAL_INVALID, 'the credential lacks the required scope', $detail); + } + if ($status === 404) { + return new Tiger_Git_Exception(Tiger_Git_Exception::INVALID_REF, 'repository or ref not found', $detail); + } + if ($status === 409 || $status === 422) { + if (stripos($msg, 'fast forward') !== false || stripos($msg, 'fast-forward') !== false) { + return new Tiger_Git_Exception(Tiger_Git_Exception::NON_FAST_FORWARD, 'the branch moved — update is not a fast-forward', $detail); + } + return new Tiger_Git_Exception(Tiger_Git_Exception::API_ERROR, 'GitHub rejected the request', $detail); + } + return new Tiger_Git_Exception(Tiger_Git_Exception::API_ERROR, 'GitHub API error', $detail); + } + + /** + * The raw HTTP call — the seam. Tests inject canned responses; production uses cURL. Redirects are + * never auto-followed (the caller inspects the Location so it can confirm the host allowlist before + * fetching). Returns `{status:int, body:string, headers:array}`. + * + * @param array $headers + * @return array{status:int,body:string,headers:array} + */ + private function send(string $method, string $url, array $headers, ?string $body): array + { + if ($this->http !== null) { + $res = ($this->http)($method, $url, $headers, $body); + return [ + 'status' => (int) ($res['status'] ?? 0), + 'body' => (string) ($res['body'] ?? ''), + 'headers' => is_array($res['headers'] ?? null) ? $res['headers'] : [], + ]; + } + if (!function_exists('curl_init')) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::TRANSPORT_UNAVAILABLE, 'cURL is not available for the API transport'); + } + $ch = curl_init($url); + $hdr = []; + foreach ($headers as $k => $v) { $hdr[] = $k . ': ' . $v; } + $respHeaders = []; + $opts = [ + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $hdr, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 60, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_FOLLOWLOCATION => false, // never follow a redirect off the allowlisted host + CURLOPT_HEADERFUNCTION => static function ($ch, string $line) use (&$respHeaders): int { + $p = strpos($line, ':'); + if ($p !== false) { $respHeaders[strtolower(trim(substr($line, 0, $p)))] = trim(substr($line, $p + 1)); } + return strlen($line); + }, + ]; + if ($body !== null) { $opts[CURLOPT_POSTFIELDS] = $body; } + curl_setopt_array($ch, $opts); + $out = curl_exec($ch); + $errno = curl_errno($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + if ($errno !== 0) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::API_ERROR, 'the GitHub request failed', ['curl' => $errno]); + } + return ['status' => $code, 'body' => (string) $out, 'headers' => $respHeaders]; + } +} diff --git a/library/Tiger/Git/Transport/Interface.php b/library/Tiger/Git/Transport/Interface.php new file mode 100644 index 00000000..7d2a462a --- /dev/null +++ b/library/Tiger/Git/Transport/Interface.php @@ -0,0 +1,100 @@ +, clean:bool} + */ + public function status(Tiger_Git_WorkingTree $tree): array; + + /** + * A size-capped diff summary for the working tree (never full file bodies beyond the cap). + * + * @param array $opts {paths?:string[], max_bytes?:int} + * @return array{files:array, truncated:bool} + */ + public function diff(Tiger_Git_WorkingTree $tree, array $opts = []): array; + + /** + * Commit the given paths (relative to the module dir; empty = all tracked changes) on the current + * branch and return the new commit SHA. + * + * @param string $message the commit message + * @param string[] $paths module-relative paths, or [] for all changes + * @return string the new commit SHA + */ + public function commit(Tiger_Git_WorkingTree $tree, string $message, array $paths = []): string; + + /** Local branch names. @return string[] */ + public function branches(Tiger_Git_WorkingTree $tree): array; + + /** Create a branch off the current HEAD (never a protected/default branch name). */ + public function createBranch(Tiger_Git_WorkingTree $tree, string $name): void; + + /** Switch the working tree to an existing branch (refuses on a dirty tree). */ + public function switchBranch(Tiger_Git_WorkingTree $tree, string $name): void; + + /** + * Push the current branch to the remote. Fast-forward only — a non-fast-forward is reported, never + * forced. + * + * @return array{ref:string, pushed:bool} + */ + public function push(Tiger_Git_WorkingTree $tree, string $branch): array; + + /** + * Fast-forward pull of $branch. A non-fast-forward (divergence) or a merge conflict aborts cleanly, + * leaves the working tree unchanged, and is reported. + * + * @return array{ref:string, updated:bool, conflicts:string[]} + */ + public function pull(Tiger_Git_WorkingTree $tree, string $branch): array; + + /** + * Create an annotated tag at $ref (default: current HEAD of the default branch). Semver is enforced + * by the caller; a submitted tag is immutable (never moved or deleted). + * + * @return array{tag:string, sha:string} + */ + public function tag(Tiger_Git_WorkingTree $tree, string $tag, ?string $ref = null): array; + + /** + * Releases on the remote, newest first. + * + * @return array + */ + public function listReleases(Tiger_Git_WorkingTree $tree): array; + + /** + * Download a release archive (the tag's tree) to $destFile and return its path. The ONLY artifact + * an update ever installs — never an arbitrary branch. + * + * @return string the written file path + */ + public function fetchArchive(Tiger_Git_WorkingTree $tree, string $tag, string $destFile): string; +} diff --git a/library/Tiger/Git/Transport/Shell.php b/library/Tiger/Git/Transport/Shell.php new file mode 100644 index 00000000..907fd87f --- /dev/null +++ b/library/Tiger/Git/Transport/Shell.php @@ -0,0 +1,320 @@ +dir()`. + * + * Safety is structural: git is invoked through `proc_open` with an ARGUMENT ARRAY (never a shell + * string), so nothing a caller passes can be interpreted as a shell metacharacter; only a fixed set of + * subcommands is ever run; every file path is resolved under the module root first (refusing an escape); + * pushes are fast-forward only (no `--force`, ever); and a credential is injected through an inline + * `credential.helper` that reads the token from the process ENVIRONMENT (`GIT_TOKEN`), so the secret is + * never in argv (visible to `ps`) nor written to `.git/config`. + * + * GitHub *releases* and release *archives* are a provider-API concept, not a git one — the GitHub + * adapter routes those to Tiger_Git_Transport_Api regardless of the working transport — so + * `listReleases()`/`fetchArchive()` here report NOT_IMPLEMENTED by design. + * + * @api + * @since 1.38.0 + */ +class Tiger_Git_Transport_Shell implements Tiger_Git_Transport_Interface +{ + /** @var Tiger_Git_Credential resolves a connection's credential ref to a token (never returned out) */ + private Tiger_Git_Credential $credentials; + + /** @var null|callable(array,string,array):array the command-runner seam; null = real proc_open */ + private $runner; + + public function __construct(Tiger_Git_Credential $credentials, ?callable $runner = null) + { + $this->credentials = $credentials; + $this->runner = $runner; + } + + /** Swap the command runner (tests stub git output). Null restores real proc_open. */ + public function setRunner(?callable $runner): void + { + $this->runner = $runner; + } + + public function name(): string + { + return Tiger_Git::TRANSPORT_SHELL; + } + + // --- status / diff --------------------------------------------------------------------------- + + public function status(Tiger_Git_WorkingTree $tree): array + { + $branch = trim($this->ok($tree, ['rev-parse', '--abbrev-ref', 'HEAD'])['out']); + + $porcelain = $this->ok($tree, ['status', '--porcelain'])['out']; + $changed = []; + foreach (preg_split('/\R/', $porcelain) as $line) { + if ($line === '') { continue; } + $state = trim(substr($line, 0, 2)); + $path = substr($line, 3); + if (str_contains($path, ' -> ')) { $path = substr($path, strpos($path, ' -> ') + 4); } // rename + $changed[] = ['path' => $path, 'state' => $state]; + } + + [$ahead, $behind] = $this->aheadBehind($tree); + + return [ + 'branch' => $branch, + 'ahead' => $ahead, + 'behind' => $behind, + 'changed' => $changed, + 'clean' => $changed === [], + ]; + } + + public function diff(Tiger_Git_WorkingTree $tree, array $opts = []): array + { + $args = ['diff', '--numstat']; + if (!empty($opts['paths']) && is_array($opts['paths'])) { + $args[] = '--'; + foreach ($opts['paths'] as $p) { $tree->resolve((string) $p); $args[] = (string) $p; } + } + $out = $this->ok($tree, $args)['out']; + $cap = (int) ($opts['max_bytes'] ?? 0); + $files = []; + $truncated = false; + foreach (preg_split('/\R/', $out) as $line) { + if ($line === '') { continue; } + $parts = preg_split('/\t/', $line); + if (count($parts) < 3) { continue; } + [$add, $rem, $path] = $parts; + $binary = ($add === '-' && $rem === '-'); + $files[] = ['path' => $path, 'added' => $binary ? 0 : (int) $add, 'removed' => $binary ? 0 : (int) $rem, 'binary' => $binary]; + if ($cap > 0 && count($files) >= $cap) { $truncated = true; break; } + } + return ['files' => $files, 'truncated' => $truncated]; + } + + // --- commit ---------------------------------------------------------------------------------- + + public function commit(Tiger_Git_WorkingTree $tree, string $message, array $paths = []): string + { + if (trim($message) === '') { + throw new Tiger_Git_Exception(Tiger_Git_Exception::INVALID_REF, 'a commit message is required'); + } + + if ($paths === []) { + $this->ok($tree, ['add', '-A']); + } else { + $add = ['add', '--']; + foreach ($paths as $p) { $tree->resolve((string) $p); $add[] = (string) $p; } + $this->ok($tree, $add); + } + + [$name, $email] = $this->authorIdentity(); + $res = $this->run($tree, ['-c', 'user.name=' . $name, '-c', 'user.email=' . $email, 'commit', '-m', $message]); + if ($res['code'] !== 0) { + if (stripos($res['out'] . $res['err'], 'nothing to commit') !== false) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::NOTHING_TO_COMMIT, 'there is nothing to commit'); + } + throw $this->shellError($res, 'commit failed'); + } + return trim($this->ok($tree, ['rev-parse', 'HEAD'])['out']); + } + + // --- branches -------------------------------------------------------------------------------- + + public function branches(Tiger_Git_WorkingTree $tree): array + { + $out = $this->ok($tree, ['branch', '--format=%(refname:short)'])['out']; + return array_values(array_filter(array_map('trim', preg_split('/\R/', $out)), static fn ($b) => $b !== '')); + } + + public function createBranch(Tiger_Git_WorkingTree $tree, string $name): void + { + $this->assertRef($name); + if ($name === $tree->defaultBranch()) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::PROTECTED_BRANCH, 'refusing to recreate the default branch'); + } + $this->ok($tree, ['branch', $name]); // off the current HEAD, no checkout + } + + public function switchBranch(Tiger_Git_WorkingTree $tree, string $name): void + { + $this->assertRef($name); + if (!$this->status($tree)['clean']) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::DIRTY_TREE, 'refusing to switch with uncommitted changes'); + } + $res = $this->run($tree, ['switch', $name]); + if ($res['code'] !== 0) { throw $this->shellError($res, 'branch switch failed'); } + } + + // --- push / pull (network) ------------------------------------------------------------------- + + public function push(Tiger_Git_WorkingTree $tree, string $branch): array + { + $this->assertRef($branch); + $res = $this->run($tree, ['push', 'origin', $branch], $this->authEnv($tree)); // NO --force, ever + if ($res['code'] !== 0) { throw $this->pushPullError($res); } + return ['ref' => trim($this->ok($tree, ['rev-parse', $branch])['out']), 'pushed' => true]; + } + + public function pull(Tiger_Git_WorkingTree $tree, string $branch): array + { + $this->assertRef($branch); + $res = $this->run($tree, ['pull', '--ff-only', 'origin', $branch], $this->authEnv($tree)); + if ($res['code'] !== 0) { throw $this->pushPullError($res); } + return ['ref' => trim($this->ok($tree, ['rev-parse', 'HEAD'])['out']), 'updated' => true, 'conflicts' => []]; + } + + // --- tag ------------------------------------------------------------------------------------- + + public function tag(Tiger_Git_WorkingTree $tree, string $tag, ?string $ref = null): array + { + $this->assertRef($tag); + $args = ['tag', '-a', $tag, '-m', $tag]; + if ($ref !== null && $ref !== '') { $this->assertRef($ref); $args[] = $ref; } + $this->ok($tree, $args); + + // A release tag is only useful on the remote — push it (auth, never forced). + $push = $this->run($tree, ['push', 'origin', 'refs/tags/' . $tag], $this->authEnv($tree)); + if ($push['code'] !== 0) { throw $this->pushPullError($push); } + + return ['tag' => $tag, 'sha' => trim($this->ok($tree, ['rev-parse', $tag . '^{commit}'])['out'])]; + } + + // --- provider-API concerns (not git) --------------------------------------------------------- + + public function listReleases(Tiger_Git_WorkingTree $tree): array + { + throw new Tiger_Git_Exception(Tiger_Git_Exception::NOT_IMPLEMENTED, 'GitHub releases are a provider-API concern — use the GitHub adapter'); + } + + public function fetchArchive(Tiger_Git_WorkingTree $tree, string $tag, string $destFile): string + { + throw new Tiger_Git_Exception(Tiger_Git_Exception::NOT_IMPLEMENTED, 'a release archive is a provider-API concern — use the GitHub adapter'); + } + + // --- internals ------------------------------------------------------------------------------- + + /** The ahead/behind counts vs the upstream, or [0,0] when there is no upstream. */ + private function aheadBehind(Tiger_Git_WorkingTree $tree): array + { + $up = $this->run($tree, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']); + if ($up['code'] !== 0) { return [0, 0]; } + $counts = $this->run($tree, ['rev-list', '--left-right', '--count', '@{upstream}...HEAD']); + if ($counts['code'] !== 0) { return [0, 0]; } + $parts = preg_split('/\s+/', trim($counts['out'])); + return [(int) ($parts[1] ?? 0), (int) ($parts[0] ?? 0)]; // [ahead (right), behind (left)] + } + + /** The commit author identity (config-overridable), with safe defaults so commit never fails unconfigured. */ + private function authorIdentity(): array + { + $name = 'Tiger'; + $email = 'tiger@localhost'; + try { + if (Zend_Registry::isRegistered('Zend_Config')) { + $cfg = Zend_Registry::get('Zend_Config'); + $g = $cfg->tiger->git->author ?? null; + if ($g) { + $name = (string) ($g->name ?: $name); + $email = (string) ($g->email ?: $email); + } + } + } catch (\Throwable $e) { /* defaults */ } + return [$name, $email]; + } + + /** + * The environment for a network op: the resolved token in `GIT_TOKEN` + an inline credential helper + * that reads it (so the secret is never in argv or `.git/config`). Empty when there's no token (a + * public / file / ssh-agent remote), which is fine — git just proceeds unauthenticated. + * + * @return array + */ + private function authEnv(Tiger_Git_WorkingTree $tree): array + { + $token = $this->credentials->resolve($tree->credentialRef()); + if ($token === null || $token === '') { return []; } + return ['GIT_TOKEN' => $token, 'GIT_TERMINAL_PROMPT' => '0']; + } + + /** Extra `-c` args that wire the inline credential helper (used only when a token is present). */ + private function authArgs(Tiger_Git_WorkingTree $tree): array + { + $token = $this->credentials->resolve($tree->credentialRef()); + if ($token === null || $token === '') { return []; } + // The helper references $GIT_TOKEN from the env — no secret in argv. + return ['-c', 'credential.helper=', '-c', 'credential.helper=!f() { echo username=x-access-token; echo "password=$GIT_TOKEN"; }; f']; + } + + /** A ref/branch/tag name git will accept and that carries no shell/option trickery. */ + private function assertRef(string $name): void + { + if ($name === '' || $name[0] === '-' || !preg_match('~^[A-Za-z0-9._/-]+$~', $name) || str_contains($name, '..')) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::INVALID_REF, 'invalid ref name'); + } + } + + /** Run git and throw SHELL_ERROR on any non-zero exit (for commands expected to succeed). */ + private function ok(Tiger_Git_WorkingTree $tree, array $args, array $env = []): array + { + $res = $this->run($tree, $args, $env); + if ($res['code'] !== 0) { throw $this->shellError($res, 'git ' . ($args[0] ?? '') . ' failed'); } + return $res; + } + + private function shellError(array $res, string $what): Tiger_Git_Exception + { + return new Tiger_Git_Exception(Tiger_Git_Exception::SHELL_ERROR, $what, ['code' => $res['code']]); + } + + /** Map a push/pull failure to NON_FAST_FORWARD / CREDENTIAL_INVALID / SHELL_ERROR. */ + private function pushPullError(array $res): Tiger_Git_Exception + { + $blob = strtolower($res['out'] . ' ' . $res['err']); + if (str_contains($blob, 'non-fast-forward') || str_contains($blob, 'fetch first') || str_contains($blob, 'not possible to fast-forward')) { + return new Tiger_Git_Exception(Tiger_Git_Exception::NON_FAST_FORWARD, 'the branch moved — update is not a fast-forward'); + } + if (str_contains($blob, 'authentication failed') || str_contains($blob, 'could not read username') || str_contains($blob, 'terminal prompts disabled') || str_contains($blob, '403')) { + return new Tiger_Git_Exception(Tiger_Git_Exception::CREDENTIAL_INVALID, 'the credential was rejected by the remote'); + } + return new Tiger_Git_Exception(Tiger_Git_Exception::SHELL_ERROR, 'the git remote operation failed', ['code' => $res['code']]); + } + + /** + * Run `git ` in the module dir. A network op's $env carries the token + its inline helper args + * are prepended. Returns `{code:int, out:string, err:string}`. The command is an ARG ARRAY — no shell. + * + * @param array $args + * @param array $env + * @return array{code:int,out:string,err:string} + */ + private function run(Tiger_Git_WorkingTree $tree, array $args, array $env = []): array + { + // A token in the env means this is an authenticated op — wire the helper args in front. + if (isset($env['GIT_TOKEN'])) { $args = array_merge($this->authArgs($tree), $args); } + + if ($this->runner !== null) { + $res = ($this->runner)($args, $tree->dir(), $env); + return ['code' => (int) ($res['code'] ?? 0), 'out' => (string) ($res['out'] ?? ''), 'err' => (string) ($res['err'] ?? '')]; + } + if (!Tiger_Git_Environment::execUsable()) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::TRANSPORT_UNAVAILABLE, 'proc_open is unavailable for the shell transport'); + } + $desc = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; + $procEnv = $env === [] ? null : array_merge(getenv() ?: [], $env); // null = inherit; else merge over it + $proc = proc_open(array_merge(['git'], $args), $desc, $pipes, $tree->dir(), $procEnv); + if (!is_resource($proc)) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::SHELL_ERROR, 'could not start git'); + } + $out = stream_get_contents($pipes[1]) ?: ''; + $err = stream_get_contents($pipes[2]) ?: ''; + foreach ($pipes as $p) { if (is_resource($p)) { fclose($p); } } + $code = proc_close($proc); + return ['code' => (int) $code, 'out' => (string) $out, 'err' => (string) $err]; + } +} diff --git a/library/Tiger/Git/WorkingTree.php b/library/Tiger/Git/WorkingTree.php new file mode 100644 index 00000000..012eff15 --- /dev/null +++ b/library/Tiger/Git/WorkingTree.php @@ -0,0 +1,112 @@ +dir = $real; + $this->owner = $owner; + $this->repo = $repo; + $this->defaultBranch = $defaultBranch; + $this->currentBranch = $currentBranch; + $this->transport = $transport; + $this->credentialRef = $credentialRef; + $this->writeEnabled = $writeEnabled; + } + + public function dir(): string { return $this->dir; } + public function owner(): string { return $this->owner; } + public function repo(): string { return $this->repo; } + public function slug(): string { return $this->owner . '/' . $this->repo; } + public function defaultBranch(): string { return $this->defaultBranch; } + public function currentBranch(): string { return $this->currentBranch; } + public function transport(): string { return $this->transport; } + public function credentialRef(): string { return $this->credentialRef; } + public function writeEnabled(): bool { return $this->writeEnabled; } + + /** + * Resolve a module-relative path to an absolute path UNDER the tree root, or throw PATH_ESCAPE. + * The guard for every file a transport reads/writes — `..`, an absolute path, or a symlink that + * points outside the module dir are all refused. + * + * @param string $relative a module-relative path + * @return string the safe absolute path (may not yet exist, but its parent is in-tree) + */ + public function resolve(string $relative): string + { + $relative = str_replace('\\', '/', $relative); + if ($relative === '' || $relative[0] === '/' || preg_match('~(^|/)\.\.(/|$)~', $relative)) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::PATH_ESCAPE, 'path escapes the module directory'); + } + $target = $this->dir . '/' . $relative; + // For an existing path, the real path must sit under the root (catches symlink escapes). + $real = realpath($target); + if ($real !== false) { + if ($real !== $this->dir && strpos($real, $this->dir . '/') !== 0) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::PATH_ESCAPE, 'path escapes the module directory'); + } + return $real; + } + // For a not-yet-existing path, its nearest existing ancestor must be in-tree. + $parent = realpath(dirname($target)); + if ($parent === false || ($parent !== $this->dir && strpos($parent, $this->dir . '/') !== 0)) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::PATH_ESCAPE, 'path escapes the module directory'); + } + return $target; + } + + /** A copy on a different current branch (after a switch). */ + public function withBranch(string $branch): self + { + return new self($this->dir, $this->owner, $this->repo, $this->defaultBranch, $branch, $this->transport, $this->credentialRef, $this->writeEnabled); + } +} diff --git a/library/Tiger/Mcp/Token.php b/library/Tiger/Mcp/Token.php index 9394d78b..2d198317 100644 --- a/library/Tiger/Mcp/Token.php +++ b/library/Tiger/Mcp/Token.php @@ -23,7 +23,7 @@ class Tiger_Mcp_Token const OPT_METER = 'mcp.meter'; /** The curated starter set a fresh MCP token is scoped to (the mainstream, mostly-content verbs). */ - const DEFAULT_MODULES = ['cms', 'blog', 'media', 'search', 'docs']; + const DEFAULT_MODULES = ['cms', 'blog', 'media', 'search', 'docs', 'git']; /** Soft rate limit: at most CAP tool calls per WINDOW seconds, per token. */ const RATE_CAP = 600; diff --git a/library/Tiger/Model/GitCredential.php b/library/Tiger/Model/GitCredential.php new file mode 100644 index 00000000..a8bde467 --- /dev/null +++ b/library/Tiger/Model/GitCredential.php @@ -0,0 +1,53 @@ +fetchRow( + $this->activeSelect() + ->where('org_id = ?', (string) $orgId) + ->where('module_key = ?', (string) $moduleKey) + ->where('revoked_at IS NULL') + ->order('created_at DESC') + ); + } + + /** + * The public, SAFE view of a credential row — never the ciphertext. What any surface may show. + * + * @param Zend_Db_Table_Row_Abstract $row + * @return array{id:string, type:string, fingerprint:?string, public_material:?string, scopes:?string, rotated_at:?string, revoked_at:?string} + */ + public static function publicView($row): array + { + return [ + 'id' => (string) $row->git_credential_id, + 'type' => (string) $row->type, + 'fingerprint' => $row->fingerprint !== null ? (string) $row->fingerprint : null, + 'public_material' => $row->public_material !== null ? (string) $row->public_material : null, + 'scopes' => $row->scopes !== null ? (string) $row->scopes : null, + 'rotated_at' => $row->rotated_at !== null ? (string) $row->rotated_at : null, + 'revoked_at' => $row->revoked_at !== null ? (string) $row->revoked_at : null, + ]; + } +} diff --git a/library/Tiger/Model/GitOperationLog.php b/library/Tiger/Model/GitOperationLog.php new file mode 100644 index 00000000..30a598bf --- /dev/null +++ b/library/Tiger/Model/GitOperationLog.php @@ -0,0 +1,59 @@ +fetchAll( + $this->select() + ->where('org_id = ?', (string) $orgId) + ->where('module_key = ?', (string) $moduleKey) + ->order('created_at DESC') + ->limit(max(1, (int) $limit)) + ); + } + + /** + * Record one operation. Returns the new log id. Fail-soft is the CALLER's concern — the audit + * write must not be swallowed silently, but it must also never carry a secret (enforced by the + * column set, not by trust). + * + * @param array $fields org_id, module_key, actor_type, actor_id, operation, branch?, ref_before?, ref_after?, result, error_code? + * @return string + */ + public function record(array $fields): string + { + return (string) $this->insert([ + 'org_id' => (string) ($fields['org_id'] ?? ''), + 'module_key' => (string) ($fields['module_key'] ?? ''), + 'actor_type' => (string) ($fields['actor_type'] ?? self::ACTOR_USER), + 'actor_id' => $fields['actor_id'] ?? null, + 'operation' => (string) ($fields['operation'] ?? ''), + 'branch' => $fields['branch'] ?? null, + 'ref_before' => $fields['ref_before'] ?? null, + 'ref_after' => $fields['ref_after'] ?? null, + 'result' => (string) ($fields['result'] ?? self::RESULT_OK), + 'error_code' => $fields['error_code'] ?? null, + ]); + } +} diff --git a/library/Tiger/Model/GitRepoConnection.php b/library/Tiger/Model/GitRepoConnection.php new file mode 100644 index 00000000..bfde2c2f --- /dev/null +++ b/library/Tiger/Model/GitRepoConnection.php @@ -0,0 +1,36 @@ +fetchRow( + $this->activeSelect() + ->where('org_id = ?', (string) $orgId) + ->where('module_key = ?', (string) $moduleKey) + ); + } + + /** Every active connection in an org (for the instance-wide Updates panel). */ + public function forOrg($orgId) + { + return $this->fetchAll( + $this->activeSelect()->where('org_id = ?', (string) $orgId)->order('module_key ASC') + ); + } +} diff --git a/library/Tiger/Model/ModuleInstallPin.php b/library/Tiger/Model/ModuleInstallPin.php new file mode 100644 index 00000000..bdf23a0c --- /dev/null +++ b/library/Tiger/Model/ModuleInstallPin.php @@ -0,0 +1,37 @@ +fetchRow( + $this->activeSelect() + ->where('org_id = ?', (string) $orgId) + ->where('module_key = ?', (string) $moduleKey) + ); + } + + /** Every active pin in an org (the Updates panel). */ + public function forOrg($orgId) + { + return $this->fetchAll( + $this->activeSelect()->where('org_id = ?', (string) $orgId)->order('module_key ASC') + ); + } +} diff --git a/library/Tiger/Model/ModuleRelease.php b/library/Tiger/Model/ModuleRelease.php new file mode 100644 index 00000000..21ec34e0 --- /dev/null +++ b/library/Tiger/Model/ModuleRelease.php @@ -0,0 +1,58 @@ +fetchAll( + $this->activeSelect() + ->where('org_id = ?', (string) $orgId) + ->where('module_key = ?', (string) $moduleKey) + ->where('status = ?', self::STATUS_APPROVED) + ->order('created_at DESC') + ); + } + + /** Every active release for a module, newest first (the release list — any status). */ + public function forModule($orgId, $moduleKey) + { + return $this->fetchAll( + $this->activeSelect() + ->where('org_id = ?', (string) $orgId) + ->where('module_key = ?', (string) $moduleKey) + ->order('created_at DESC') + ); + } + + /** A specific release by tag (to check status / prevent tag reuse), or null. */ + public function byTag($orgId, $moduleKey, $tag) + { + return $this->fetchRow( + $this->activeSelect() + ->where('org_id = ?', (string) $orgId) + ->where('module_key = ?', (string) $moduleKey) + ->where('git_tag = ?', (string) $tag) + ); + } +} diff --git a/library/Tiger/Version.php b/library/Tiger/Version.php index 196a3fcf..56f141fc 100644 --- a/library/Tiger/Version.php +++ b/library/Tiger/Version.php @@ -9,5 +9,5 @@ class Tiger_Version { /** Current Tiger Core version. Keep in lockstep with the git tag cut for a release. */ - const VERSION = '1.37.0'; + const VERSION = '1.38.0'; } diff --git a/modules/git/Bootstrap.php b/modules/git/Bootstrap.php new file mode 100644 index 00000000..8a23e299 --- /dev/null +++ b/modules/git/Bootstrap.php @@ -0,0 +1,19 @@ +' + esc(text) + ''; } + + function connectionCard(c) { + var statusCls = c.status === 'connected' ? 'text-bg-success' : (c.status === 'degraded' ? 'text-bg-warning' : 'text-bg-secondary'); + var verified = c.last_verified_at ? ('· verified ' + esc(c.last_verified_at)) : ''; + return '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '

' + esc(c.slug) + '

' + + '
' + esc(c.module_key) + ' · ' + esc(c.default_branch) + ' ' + verified + '
' + + '
' + + '
' + + badge(c.transport, 'text-bg-info') + + badge(c.status, statusCls) + + (c.write_enabled ? badge('write', 'text-bg-dark') : '') + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '' + + '
' + + '
' + + '
' + + '
'; + } + + function loadConnections() { + return git('repo', 'list', {}).then(function (res) { + if (!res || res.result !== 1) { flash(res); list.innerHTML = ''; return; } + var rows = (res.data && res.data.connections) || []; + if (!rows.length) { + list.innerHTML = '
' + + 'No modules are connected yet. Use “Connect a module” above.
'; + return; + } + list.innerHTML = rows.map(connectionCard).join(''); + }); + } + + // ---- per-connection actions (event delegation) ------------------------------------------ + + if (list) { + list.addEventListener('click', function (e) { + var btn = e.target.closest('[data-act]'); + if (!btn) { return; } + var card = btn.closest('[data-module]'); + var mod = card ? card.getAttribute('data-module') : ''; + var act = btn.getAttribute('data-act'); + if (act === 'check') { return doCheck(btn, mod, card); } + if (act === 'recheck') { return doRecheck(btn, mod); } + if (act === 'releases') { return openReleases(mod); } + if (act === 'activity') { return openActivity(mod); } + if (act === 'disconnect') { return doDisconnect(mod); } + if (act === 'apply') { return doApply(btn, mod); } + if (act === 'rollback') { return doRollback(btn, mod); } + }); + + list.addEventListener('change', function (e) { + var sel = e.target.closest('[data-act="policy"]'); + if (!sel) { return; } + var mod = sel.closest('[data-module]').getAttribute('data-module'); + git('update', 'setPolicy', { module_key: mod, channel: sel.value }).then(function (res) { + notify((res && res.result === 1) ? 'Update policy saved.' : 'Could not save the policy.', (res && res.result === 1) ? 'success' : 'error'); + }); + }); + } + + function updateStateHtml(u) { + var policy = ''; + var line = 'Installed: ' + esc(u.installed_version || '—') + ' · latest approved: ' + esc(u.latest_approved_version || '—') + '' + policy; + if (u.pending) { + line += ' '; + } else { + line += ' Up to date'; + } + line += ' '; + return line; + } + + function doCheck(btn, mod, card) { + TigerButton.run(btn, function () { return git('update', 'check', { module_key: mod }); }).then(function (res) { + var slot = card.querySelector('.git-update-state'); + if (!res || res.result !== 1) { flash(res); return; } + var u = (res.data.updates || [])[0]; + slot.innerHTML = u ? updateStateHtml(u) : 'No release information.'; + }); + } + + function doApply(btn, mod) { + TigerButton.run(btn, function () { return git('update', 'apply', { module_key: mod }); }).then(function (res) { + flash(res); + if (res && res.result === 1) { doCheckByModule(mod); } + }); + } + + function doRollback(btn, mod) { + TigerButton.run(btn, function () { return git('update', 'rollback', { module_key: mod }); }).then(function (res) { + flash(res); + if (res && res.result === 1) { doCheckByModule(mod); } + }); + } + + function doCheckByModule(mod) { + var card = list.querySelector('[data-module="' + (window.CSS && CSS.escape ? CSS.escape(mod) : mod) + '"]'); + if (card) { doCheck(card.querySelector('[data-act="check"]'), mod, card); } + } + + function doRecheck(btn, mod) { + TigerButton.run(btn, function () { return git('repo', 'recheck', { module_key: mod }); }).then(function (res) { + flash(res); + if (res && res.result === 1) { loadConnections(); } + }); + } + + function doDisconnect(mod) { + TigerModal.confirm({ + title: 'Disconnect module', + body: 'Disconnect “' + mod + '” from its repository? The stored credential is revoked. The module’s files are untouched.', + confirmLabel: 'Disconnect', variant: 'danger' + }).then(function (ok) { + if (!ok) { return; } + git('repo', 'disconnect', { module_key: mod }).then(function (res) { + flash(res); + if (res && res.result === 1) { loadConnections(); } + }); + }); + } + + // ---- connect ---------------------------------------------------------------------------- + + var connectForm = document.getElementById('git-connect-form'); + var connectSave = document.getElementById('git-connect-save'); + if (connectSave) { + connectSave.addEventListener('click', function () { + TigerButton.run(this, function () { + return postForm(connectForm, { module: 'git', service: 'repo', method: 'connect' }); + }).then(function (res) { + flash(res); + if (res && res.result === 1) { + connectForm.reset(); + loadConnections(); + } else if (res && res.form) { + Object.keys(res.form).forEach(function (f) { + var el = connectForm.querySelector('[name="' + f + '"]'); + if (el) { el.classList.add('is-invalid'); } + }); + } + }); + }); + } + + // ---- releases + QA ---------------------------------------------------------------------- + + var relModal, actModal; + var relFb = document.getElementById('git-release-feedback'); + + function relStatusBadge(s) { + var map = { approved: 'text-bg-success', submitted: 'text-bg-warning', in_qa: 'text-bg-info', rejected: 'text-bg-danger', withdrawn: 'text-bg-secondary' }; + return '' + esc(s) + ''; + } + + function releaseRow(r) { + var actions = ''; + if (r.status === 'submitted' || r.status === 'in_qa') { + actions = + '' + + ''; + } + return '' + esc(r.version) + '' + relStatusBadge(r.status) + '' + actions + ''; + } + + function loadReleases(mod) { + var rows = document.getElementById('git-releases-rows'); + rows.innerHTML = ''; + return git('release', 'list', { module_key: mod }).then(function (res) { + if (!res || res.result !== 1) { flash(res, relFb); rows.innerHTML = ''; return; } + var rel = res.data.releases || []; + rows.innerHTML = rel.length ? rel.map(releaseRow).join('') : 'No releases yet.'; + }); + } + + function openReleases(mod) { + document.getElementById('git-rel-module').value = mod; + document.getElementById('git-releases-title').textContent = 'Releases — ' + mod; + var tagForm = document.getElementById('git-tag-form'); + if (tagForm) { tagForm.reset(); document.getElementById('git-rel-module').value = mod; } + if (!relModal) { relModal = new bootstrap.Modal(document.getElementById('git-releases-modal')); } + loadReleases(mod).then(function () { relModal.show(); }); + } + + var tagBtn = document.getElementById('git-tag-btn'); + if (tagBtn) { + tagBtn.addEventListener('click', function () { + var mod = document.getElementById('git-rel-module').value; + var version = document.getElementById('git-rel-version').value; + TigerButton.run(this, function () { return git('release', 'tag', { module_key: mod, version: version }); }).then(function (res) { + flash(res, relFb); + if (res && res.result === 1 && res.data) { + document.getElementById('git-rel-sha').value = res.data.sha || ''; + notify('Tagged ' + esc(res.data.tag) + ' — the commit sha is filled in; now submit it to QA.', 'info', relFb); + loadReleases(mod); + } + }); + }); + } + + var submitBtn = document.getElementById('git-submit-btn'); + if (submitBtn) { + submitBtn.addEventListener('click', function () { + var mod = document.getElementById('git-rel-module').value; + var tagForm = document.getElementById('git-tag-form'); + TigerButton.run(this, function () { + return postForm(tagForm, { module: 'git', service: 'release', method: 'submit', module_key: mod }); + }).then(function (res) { + flash(res, relFb); + if (res && res.result === 1) { loadReleases(mod); } + }); + }); + } + + var relRowsEl = document.getElementById('git-releases-rows'); + if (relRowsEl) { + relRowsEl.addEventListener('click', function (e) { + var b = e.target.closest('[data-rel-act]'); + if (!b) { return; } + var decision = b.getAttribute('data-rel-act'); + var id = b.getAttribute('data-id'); + var mod = document.getElementById('git-rel-module').value; + TigerButton.run(b, function () { return git('release', 'decide', { module_release_id: id, decision: decision }); }).then(function (res) { + flash(res, relFb); + if (res && res.result === 1) { loadReleases(mod); } + }); + }); + } + + // ---- activity --------------------------------------------------------------------------- + + function openActivity(mod) { + var rows = document.getElementById('git-activity-rows'); + document.getElementById('git-activity-title').textContent = 'Activity — ' + mod; + rows.innerHTML = '
  • '; + if (!actModal) { actModal = new bootstrap.Modal(document.getElementById('git-activity-modal')); } + actModal.show(); + git('repo', 'log', { module_key: mod }).then(function (res) { + if (!res || res.result !== 1) { rows.innerHTML = '
  • No activity.
  • '; return; } + var entries = res.data.entries || []; + rows.innerHTML = entries.length ? entries.map(function (e2) { + var ok = e2.result === 'ok'; + return '
  • ' + + '' + + esc(e2.operation) + (e2.ref_after ? ' ' + esc(String(e2.ref_after).slice(0, 10)) + '' : '') + + (e2.error_code ? ' (' + esc(e2.error_code) + ')' : '') + '' + + '' + esc(e2.created_at) + '
  • '; + }).join('') : '
  • No activity yet.
  • '; + }); + } + + // ---- boot ------------------------------------------------------------------------------- + + loadConnections(); +})(); diff --git a/modules/git/configs/acl.ini b/modules/git/configs/acl.ini new file mode 100644 index 00000000..95da628d --- /dev/null +++ b/modules/git/configs/acl.ini @@ -0,0 +1,108 @@ +; ============================================================================== +; TIGER-OWNED - DO NOT EDIT. Ships in the framework and is REPLACED on every +; update (composer update / Module Manager) -- any edit here is SILENTLY LOST. +; Customize by OVERRIDING in an APP-OWNED tier (merges on top, later wins, and +; survives updates): application/configs/application.ini * local.ini (secrets) * +; the `config` DB table (runtime, no deploy). ACL rules: your OWN module's +; acl.ini or the acl_* DB tables. See ARCHITECTURE.md Sec 0 + Sec 5 (cascade). +; ============================================================================== + +; Tiger_Git module ACL (TIGER-334 / TIGER-367). Deny-by-default, one rule PER PRIVILEGE (never a +; blanket resource-level grant — the F5 anti-pattern, TIGER-335). The repo/release services drive a +; connected GitHub repo, so they are admin+. +; +; The two SHARP, owner-only operations — creating a semver TAG and SUBMITTING a release to QA — are a +; CAPABILITY resource, Tiger_Git::RESOURCE_RELEASE ("Tiger_Git_Release"), named for the capability not +; a role tier, checked in-method with _isAllowed('tag'|'submit', Tiger_Git::RESOURCE_RELEASE). The +; service is reachable by an admin (so the call dispatches), but the capability is granted only to +; SUPERADMIN, so an ordinary admin who can read releases still cannot cut one. A deployment with its own +; "owner" role grants the capability to it in an app-owned acl tier — the rule is data, not code. +[production] + +acl.resources.git_admin_ctrl.resource = "Git_AdminController" +acl.resources.git_repo_svc.resource = "Git_Service_Repo" +acl.resources.git_release_svc.resource = "Git_Service_Release" +acl.resources.git_update_svc.resource = "Git_Service_Update" +; A capability resource (not a service): the data-driven "may this role cut a release" answer. +acl.resources.git_release_cap.resource = "Tiger_Git_Release" + +; --- the admin screen (admin) ---------------------------------------------------------------- +acl.rules.git_admin_ctrl.role = "admin" +acl.rules.git_admin_ctrl.resource = "Git_AdminController" +acl.rules.git_admin_ctrl.permission = "allow" + +; --- Git_Service_Repo (admin) — one rule per public method ----------------------------------- +acl.rules.git_repo_connect.role = "admin" +acl.rules.git_repo_connect.resource = "Git_Service_Repo" +acl.rules.git_repo_connect.privilege = "connect" +acl.rules.git_repo_connect.permission = "allow" +acl.rules.git_repo_status.role = "admin" +acl.rules.git_repo_status.resource = "Git_Service_Repo" +acl.rules.git_repo_status.privilege = "status" +acl.rules.git_repo_status.permission = "allow" +acl.rules.git_repo_recheck.role = "admin" +acl.rules.git_repo_recheck.resource = "Git_Service_Repo" +acl.rules.git_repo_recheck.privilege = "recheck" +acl.rules.git_repo_recheck.permission = "allow" +acl.rules.git_repo_list.role = "admin" +acl.rules.git_repo_list.resource = "Git_Service_Repo" +acl.rules.git_repo_list.privilege = "list" +acl.rules.git_repo_list.permission = "allow" +acl.rules.git_repo_log.role = "admin" +acl.rules.git_repo_log.resource = "Git_Service_Repo" +acl.rules.git_repo_log.privilege = "log" +acl.rules.git_repo_log.permission = "allow" +acl.rules.git_repo_disconnect.role = "admin" +acl.rules.git_repo_disconnect.resource = "Git_Service_Repo" +acl.rules.git_repo_disconnect.privilege = "disconnect" +acl.rules.git_repo_disconnect.permission = "allow" + +; --- Git_Service_Release (admin reaches the service; tag/submit narrowed by the capability) -- +acl.rules.git_release_tag.role = "admin" +acl.rules.git_release_tag.resource = "Git_Service_Release" +acl.rules.git_release_tag.privilege = "tag" +acl.rules.git_release_tag.permission = "allow" +acl.rules.git_release_submit.role = "admin" +acl.rules.git_release_submit.resource = "Git_Service_Release" +acl.rules.git_release_submit.privilege = "submit" +acl.rules.git_release_submit.permission = "allow" +acl.rules.git_release_decide.role = "admin" +acl.rules.git_release_decide.resource = "Git_Service_Release" +acl.rules.git_release_decide.privilege = "decide" +acl.rules.git_release_decide.permission = "allow" +acl.rules.git_release_list.role = "admin" +acl.rules.git_release_list.resource = "Git_Service_Release" +acl.rules.git_release_list.privilege = "list" +acl.rules.git_release_list.permission = "allow" + +; --- Git_Service_Update (admin) — receive approved updates ---------------------------------- +acl.rules.git_update_check.role = "admin" +acl.rules.git_update_check.resource = "Git_Service_Update" +acl.rules.git_update_check.privilege = "check" +acl.rules.git_update_check.permission = "allow" +acl.rules.git_update_apply.role = "admin" +acl.rules.git_update_apply.resource = "Git_Service_Update" +acl.rules.git_update_apply.privilege = "apply" +acl.rules.git_update_apply.permission = "allow" +acl.rules.git_update_setpolicy.role = "admin" +acl.rules.git_update_setpolicy.resource = "Git_Service_Update" +acl.rules.git_update_setpolicy.privilege = "setPolicy" +acl.rules.git_update_setpolicy.permission= "allow" +acl.rules.git_update_rollback.role = "admin" +acl.rules.git_update_rollback.resource = "Git_Service_Update" +acl.rules.git_update_rollback.privilege = "rollback" +acl.rules.git_update_rollback.permission = "allow" + +; --- the release CAPABILITY — superadmin only (stricter than the admin-level service) -------- +acl.rules.git_release_cap_tag.role = "superadmin" +acl.rules.git_release_cap_tag.resource = "Tiger_Git_Release" +acl.rules.git_release_cap_tag.privilege = "tag" +acl.rules.git_release_cap_tag.permission = "allow" +acl.rules.git_release_cap_submit.role = "superadmin" +acl.rules.git_release_cap_submit.resource = "Tiger_Git_Release" +acl.rules.git_release_cap_submit.privilege = "submit" +acl.rules.git_release_cap_submit.permission = "allow" + +[staging : production] +[testing : production] +[development : production] diff --git a/modules/git/configs/navigation.ini b/modules/git/configs/navigation.ini new file mode 100644 index 00000000..0f148bee --- /dev/null +++ b/modules/git/configs/navigation.ini @@ -0,0 +1,12 @@ +; SPDX-License-Identifier: BSD-3-Clause +; Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers. +; +; TigerGit — top-level admin sidebar item. Discovered by Tiger_Admin_Nav (globs each active module's +; configs/navigation.ini, like acl.ini). Gated only by the ACL resource, so it shows for any role +; allowed Git_AdminController (admin+) and hides otherwise. +nav.git.label = "git.nav.label" +nav.git.icon = "fa-code-branch" +nav.git.href = "/git/admin" +nav.git.match = "/git/admin" +nav.git.resource = "Git_AdminController" +nav.git.order = 45 diff --git a/modules/git/controllers/AdminController.php b/modules/git/controllers/AdminController.php new file mode 100644 index 00000000..ae439526 --- /dev/null +++ b/modules/git/controllers/AdminController.php @@ -0,0 +1,39 @@ +view->title = 'Git — Tiger Admin'; + $this->view->connectForm = new Git_Form_Connect(); + $this->view->submitForm = new Git_Form_Submit(); + } +} diff --git a/modules/git/forms/Connect.php b/modules/git/forms/Connect.php new file mode 100644 index 00000000..88e9e0ab --- /dev/null +++ b/modules/git/forms/Connect.php @@ -0,0 +1,53 @@ + true, + 'filters' => ['StringTrim'], + 'validators' => [['StringLength', false, ['min' => 1, 'max' => 100]]], + 'attribs' => ['class' => 'form-control', 'maxlength' => 100, 'autocomplete' => 'off'], + ]], + ['text', 'repo_owner', [ + 'required' => true, + 'filters' => ['StringTrim'], + 'validators' => [['StringLength', false, ['min' => 1, 'max' => 100]]], + 'attribs' => ['class' => 'form-control', 'maxlength' => 100, 'autocomplete' => 'off'], + ]], + ['text', 'repo_name', [ + 'required' => true, + 'filters' => ['StringTrim'], + 'validators' => [['StringLength', false, ['min' => 1, 'max' => 100]]], + 'attribs' => ['class' => 'form-control', 'maxlength' => 100, 'autocomplete' => 'off'], + ]], + ['text', 'default_branch', [ + 'required' => false, + 'filters' => ['StringTrim'], + 'validators' => [['StringLength', false, ['min' => 0, 'max' => 191]]], + 'attribs' => ['class' => 'form-control', 'maxlength' => 191, 'placeholder' => 'main', 'autocomplete' => 'off'], + ]], + ['password', 'token', [ + 'required' => true, + 'filters' => ['StringTrim'], + 'validators' => [['StringLength', false, ['min' => 1, 'max' => 255]]], + 'attribs' => ['class' => 'form-control', 'autocomplete' => 'off'], + ]], + ['checkbox', 'write_enabled', [ + 'required' => false, + 'attribs' => ['class' => 'form-check-input'], + ]], + ]; + } +} diff --git a/modules/git/forms/Submit.php b/modules/git/forms/Submit.php new file mode 100644 index 00000000..7fd905ea --- /dev/null +++ b/modules/git/forms/Submit.php @@ -0,0 +1,43 @@ + true, + 'filters' => ['StringTrim'], + 'validators' => [['StringLength', false, ['min' => 1, 'max' => 100]]], + 'attribs' => ['class' => 'form-control', 'maxlength' => 100, 'autocomplete' => 'off'], + ]], + ['text', 'version', [ + 'required' => true, + 'filters' => ['StringTrim'], + 'validators' => [['StringLength', false, ['min' => 1, 'max' => 64]]], + 'attribs' => ['class' => 'form-control', 'maxlength' => 64, 'placeholder' => '1.0.0', 'autocomplete' => 'off'], + ]], + ['text', 'commit_sha', [ + 'required' => true, + 'filters' => ['StringTrim'], + 'validators' => [['Regex', false, ['pattern' => '/^[0-9a-fA-F]{7,40}$/']]], + 'attribs' => ['class' => 'form-control', 'maxlength' => 40, 'autocomplete' => 'off'], + ]], + ['text', 'qa_report_ref', [ + 'required' => false, + 'filters' => ['StringTrim'], + 'validators' => [['StringLength', false, ['min' => 0, 'max' => 191]]], + 'attribs' => ['class' => 'form-control', 'maxlength' => 191, 'autocomplete' => 'off'], + ]], + ]; + } +} diff --git a/modules/git/languages/en/git.php b/modules/git/languages/en/git.php new file mode 100644 index 00000000..655d5367 --- /dev/null +++ b/modules/git/languages/en/git.php @@ -0,0 +1,73 @@ + 'Git', + 'git.admin.title' => 'Git', + 'git.admin.subtitle' => 'Version your modules in a private repo, cut releases through QA, and receive approved updates.', + 'git.admin.connect' => 'Connect a module', + 'git.admin.connect_a_module' => 'Connect a module to its repository', + 'git.admin.loading' => 'Loading connections…', + 'git.admin.field.module_key' => 'Module', + 'git.admin.field.module_key_help'=> 'The installed module’s slug (its folder under modules/).', + 'git.admin.field.repo_owner' => 'Repo owner', + 'git.admin.field.repo_name' => 'Repo name', + 'git.admin.field.default_branch' => 'Default branch', + 'git.admin.field.token' => 'Access token', + 'git.admin.field.token_help' => 'A fine-grained token with contents access. Stored encrypted; never shown again.', + 'git.admin.field.write_enabled' => 'Allow pushes (write)', + 'git.admin.field.version' => 'Version', + 'git.admin.field.commit_sha' => 'Commit SHA', + 'git.admin.releases.title' => 'Releases', + 'git.admin.releases.tag' => 'Tag', + 'git.admin.releases.submit' => 'Submit to QA', + 'git.admin.releases.status' => 'Status', + 'git.admin.releases.actions' => 'Actions', + 'git.admin.releases.help' => 'Tag a semantic version, then submit it to QA. A released version is immutable.', + 'git.admin.activity.title' => 'Activity', + + // success + 'git.repo.connected' => 'Repository connected.', + 'git.repo.disconnected' => 'Repository disconnected.', + 'git.release.tagged' => 'Release tag created.', + 'git.release.submitted' => 'Release submitted for QA.', + 'git.release.decided' => 'Release decision recorded.', + 'git.update.applied' => 'Update applied.', + 'git.update.policy_set' => 'Update policy saved.', + 'git.update.rolled_back' => 'Rolled back to the previous version.', + + // input / state errors + 'git.error.invalid_module' => 'That module key is not valid.', + 'git.error.invalid_repo' => 'That repository owner/name is not valid.', + 'git.error.module_missing' => 'That module is not installed on this server.', + 'git.error.not_connected' => 'This module is not connected to a repository.', + 'git.error.not_allowed' => 'You do not have permission to do that.', + 'git.error.invalid_version' => 'The version must be a semantic version, e.g. 1.0.0.', + 'git.error.tag_exists' => 'That version already has a release — tags are immutable; use a new version.', + 'git.error.release_exists' => 'A release for that version is already in progress.', + 'git.error.invalid_decision'=> 'The decision must be approve or reject.', + 'git.error.release_not_found'=> 'That release could not be found.', + 'git.error.release_decided' => 'That release has already been decided.', + 'git.error.no_update' => 'There is no newer approved release to apply.', + 'git.error.not_approved' => 'That version is not an approved release.', + 'git.error.version_pinned' => 'That version is pinned after a rollback — choose a different one.', + 'git.error.no_previous' => 'There is no previous version to roll back to.', + 'git.error.invalid_channel' => 'The update policy must be auto, notify, or pinned.', + 'git.error.install_failed' => 'The update downloaded but could not be installed.', + + // transport / provider errors (mapped from Tiger_Git_Exception codes) + 'git.error.credential_invalid' => 'GitHub rejected the credential. Check the token and its access.', + 'git.error.credential_missing' => 'No usable credential is configured for this connection.', + 'git.error.non_fast_forward' => 'The branch has moved — this is not a fast-forward. Pull first.', + 'git.error.rate_limited' => 'GitHub’s rate limit was reached. Try again shortly.', + 'git.error.host_not_allowed' => 'That request would leave the allowed GitHub hosts.', + 'git.error.invalid_ref' => 'That repository, branch, or ref could not be found.', + 'git.error.protected_branch' => 'That branch is protected and cannot be changed this way.', + 'git.error.dirty_tree' => 'There are uncommitted changes — commit or discard them first.', + 'git.error.nothing_to_commit' => 'There is nothing to commit.', + 'git.error.transport_unavailable' => 'No usable git transport is available on this server.', + 'git.error.not_implemented' => 'That operation is not available on this connection’s transport yet.', + 'git.error.general' => 'The git operation could not be completed.', +]; diff --git a/modules/git/migrations/20261021000001_create_git_tables.php b/modules/git/migrations/20261021000001_create_git_tables.php new file mode 100644 index 00000000..30c8fca5 --- /dev/null +++ b/modules/git/migrations/20261021000001_create_git_tables.php @@ -0,0 +1,133 @@ + [ + // --- a module <-> repo connection ------------------------------------------------- + "CREATE TABLE `git_repo_connection` ( + `git_repo_connection_id` CHAR(36) NOT NULL, + `org_id` CHAR(36) NOT NULL, + `module_key` VARCHAR(191) NOT NULL, + `provider` VARCHAR(32) NOT NULL DEFAULT 'github', + `repo_owner` VARCHAR(100) NOT NULL, + `repo_name` VARCHAR(100) NOT NULL, + `default_branch` VARCHAR(191) NOT NULL DEFAULT 'main', + `transport` VARCHAR(16) NOT NULL DEFAULT 'api', + `write_enabled` TINYINT(1) NOT NULL DEFAULT 0, + `credential_ref` VARCHAR(191) NULL, + `last_verified_at` DATETIME NULL, + `status` VARCHAR(32) NOT NULL DEFAULT 'connected', + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + `created_by` CHAR(36) NULL, + `updated_by` CHAR(36) NULL, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NOT NULL, + `module_key_active` VARCHAR(191) GENERATED ALWAYS AS (IF(`deleted`=0, `module_key`, NULL)) VIRTUAL, + PRIMARY KEY (`git_repo_connection_id`), + UNIQUE KEY `uq_git_conn_org_module_active` (`org_id`, `module_key_active`), + KEY `ix_git_conn_org` (`org_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + + // --- a least-privilege credential, encrypted at rest ------------------------------ + "CREATE TABLE `git_credential` ( + `git_credential_id` CHAR(36) NOT NULL, + `org_id` CHAR(36) NOT NULL, + `module_key` VARCHAR(191) NOT NULL, + `type` VARCHAR(16) NOT NULL, + `ciphertext` LONGTEXT NOT NULL, + `public_material` TEXT NULL, + `fingerprint` VARCHAR(191) NULL, + `scopes` VARCHAR(255) NULL, + `rotated_at` DATETIME NULL, + `revoked_at` DATETIME NULL, + `status` VARCHAR(32) NOT NULL DEFAULT 'active', + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + `created_by` CHAR(36) NULL, + `updated_by` CHAR(36) NULL, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NOT NULL, + PRIMARY KEY (`git_credential_id`), + KEY `ix_git_cred_org_module` (`org_id`, `module_key`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + + // --- append-only audit log -------------------------------------------------------- + "CREATE TABLE `git_operation_log` ( + `git_operation_log_id` CHAR(36) NOT NULL, + `org_id` CHAR(36) NOT NULL, + `module_key` VARCHAR(191) NOT NULL, + `actor_type` VARCHAR(16) NOT NULL DEFAULT 'user', + `actor_id` CHAR(36) NULL, + `operation` VARCHAR(64) NOT NULL, + `branch` VARCHAR(191) NULL, + `ref_before` VARCHAR(64) NULL, + `ref_after` VARCHAR(64) NULL, + `result` VARCHAR(32) NOT NULL, + `error_code` VARCHAR(64) NULL, + `created_at` DATETIME NOT NULL, + PRIMARY KEY (`git_operation_log_id`), + KEY `ix_git_log_org_module` (`org_id`, `module_key`, `created_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + + // --- a submitted/approved release ------------------------------------------------- + "CREATE TABLE `module_release` ( + `module_release_id` CHAR(36) NOT NULL, + `org_id` CHAR(36) NOT NULL, + `module_key` VARCHAR(191) NOT NULL, + `version` VARCHAR(64) NOT NULL, + `git_tag` VARCHAR(191) NOT NULL, + `commit_sha` VARCHAR(64) NOT NULL, + `qa_report_ref` VARCHAR(191) NULL, + `submitted_at` DATETIME NULL, + `decided_at` DATETIME NULL, + `status` VARCHAR(32) NOT NULL DEFAULT 'submitted', + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + `created_by` CHAR(36) NULL, + `updated_by` CHAR(36) NULL, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NOT NULL, + PRIMARY KEY (`module_release_id`), + KEY `ix_module_release_org_module` (`org_id`, `module_key`, `status`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + + // --- a consumer install's version pin/policy ------------------------------------- + "CREATE TABLE `module_install_pin` ( + `module_install_pin_id` CHAR(36) NOT NULL, + `org_id` CHAR(36) NOT NULL, + `module_key` VARCHAR(191) NOT NULL, + `installed_version` VARCHAR(64) NULL, + `pinned_version` VARCHAR(64) NULL, + `channel` VARCHAR(32) NOT NULL DEFAULT 'notify', + `previous_version` VARCHAR(64) NULL, + `status` VARCHAR(32) NOT NULL DEFAULT 'active', + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + `created_by` CHAR(36) NULL, + `updated_by` CHAR(36) NULL, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NOT NULL, + `module_key_active` VARCHAR(191) GENERATED ALWAYS AS (IF(`deleted`=0, `module_key`, NULL)) VIRTUAL, + PRIMARY KEY (`module_install_pin_id`), + UNIQUE KEY `uq_module_pin_org_module_active` (`org_id`, `module_key_active`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + ], + 'down' => [ + "DROP TABLE IF EXISTS `module_install_pin`", + "DROP TABLE IF EXISTS `module_release`", + "DROP TABLE IF EXISTS `git_operation_log`", + "DROP TABLE IF EXISTS `git_credential`", + "DROP TABLE IF EXISTS `git_repo_connection`", + ], +]; diff --git a/modules/git/module.json b/modules/git/module.json new file mode 100644 index 00000000..210d987f --- /dev/null +++ b/modules/git/module.json @@ -0,0 +1,5 @@ +{ + "name": "TigerGit", + "type": "app", + "category": ["developer"] +} diff --git a/modules/git/services/Base.php b/modules/git/services/Base.php new file mode 100644 index 00000000..ff0f9fc0 --- /dev/null +++ b/modules/git/services/Base.php @@ -0,0 +1,201 @@ +_user_id === '' || (string) $this->_org_id === '') { + $this->_error('core.api.error.not_allowed'); + return false; + } + return true; + } + + /** A module key sanitized to a safe slug (letters/digits/._-), or null. Never a path. */ + protected function _slug(string $moduleKey): ?string + { + $moduleKey = trim($moduleKey); + return preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/', $moduleKey) ? $moduleKey : null; + } + + /** + * The installed module's directory (app modules first, then first-party core), or null when the + * module isn't present on disk — you can only version a module you actually have. + */ + protected function _moduleDir(string $slug): ?string + { + $slug = $this->_slug($slug); + if ($slug === null) { return null; } + foreach ([ + defined('APPLICATION_PATH') ? APPLICATION_PATH . '/modules/' . $slug : null, + defined('TIGER_CORE_PATH') ? TIGER_CORE_PATH . '/modules/' . $slug : null, + ] as $dir) { + if ($dir !== null && is_dir($dir)) { return $dir; } + } + return null; + } + + /** The GitHub provider (adapter) — the test seam if set, else the real one. */ + protected function _adapter(): Tiger_Git_Provider_Interface + { + if (self::$adapterFactory !== null) { + return (self::$adapterFactory)(); + } + return new Tiger_Git_Provider_GitHub(new Tiger_Git_Credential()); + } + + /** + * A Tiger_Git_WorkingTree for a connection row (default branch = current branch). Throws + * NOT_CONNECTED when the module isn't on disk. + */ + protected function _tree($conn): Tiger_Git_WorkingTree + { + $dir = $this->_moduleDir((string) $conn->module_key); + if ($dir === null) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::NOT_CONNECTED, 'the module is not installed on this server'); + } + return new Tiger_Git_WorkingTree( + $dir, + (string) $conn->repo_owner, + (string) $conn->repo_name, + (string) $conn->default_branch, + (string) $conn->default_branch, + (string) $conn->transport, + (string) ($conn->credential_ref ?? ''), + (bool) $conn->write_enabled + ); + } + + /** The active connection for a module in the caller's org, or null. */ + protected function _connection(string $moduleKey) + { + return (new Tiger_Model_GitRepoConnection())->forModule($this->_org_id, $moduleKey); + } + + /** The safe, secret-free view of a connection row for any surface (never the credential ref). */ + protected function _connView($conn): array + { + return [ + 'module_key' => (string) $conn->module_key, + 'provider' => (string) $conn->provider, + 'repo_owner' => (string) $conn->repo_owner, + 'repo_name' => (string) $conn->repo_name, + 'slug' => $conn->repo_owner . '/' . $conn->repo_name, + 'default_branch' => (string) $conn->default_branch, + 'transport' => (string) $conn->transport, + 'write_enabled' => (bool) $conn->write_enabled, + 'status' => (string) $conn->status, + 'last_verified_at' => $conn->last_verified_at !== null ? (string) $conn->last_verified_at : null, + ]; + } + + /** The safe view of a Tiger_Git_Environment::detect() result (no secrets — pure capability facts). */ + protected function _envView(array $env): array + { + return [ + 'transport' => (string) ($env['transport'] ?? ''), + 'usable' => (bool) ($env['usable'] ?? false), + 'git_binary' => (bool) ($env['git_binary'] ?? false), + 'exec_usable' => (bool) ($env['exec_usable'] ?? false), + 'outbound' => (bool) ($env['outbound'] ?? false), + 'git_version' => $env['git_version'] ?? null, + 'remediation' => $env['remediation'] ?? null, + ]; + } + + /** The safe view of a module_install_pin row (installed/pinned versions + policy). */ + protected function _pinView($pin): array + { + return [ + 'module_key' => (string) $pin->module_key, + 'installed_version' => $pin->installed_version !== null ? (string) $pin->installed_version : null, + 'pinned_version' => $pin->pinned_version !== null ? (string) $pin->pinned_version : null, + 'previous_version' => $pin->previous_version !== null ? (string) $pin->previous_version : null, + 'channel' => (string) $pin->channel, + 'status' => (string) $pin->status, + ]; + } + + /** Append one row to the audit trail (fail-soft — a log failure never fails the operation). */ + protected function _log(string $moduleKey, string $operation, string $result, array $extra = []): void + { + try { + (new Tiger_Model_GitOperationLog())->record(array_merge([ + 'org_id' => (string) $this->_org_id, + 'module_key' => $moduleKey, + 'actor_type' => Tiger_Model_GitOperationLog::ACTOR_USER, + 'actor_id' => $this->_user_id, + 'operation' => $operation, + 'result' => $result, + ], $extra)); + } catch (\Throwable $e) { + Tiger_Log::warn('git.audit.failed', ['operation' => $operation, 'error' => $e->getMessage()]); + } + } + + /** Map a Tiger_Git_Exception's stable code to a module i18n key (secret-free). */ + protected function _gitErrorKey(Tiger_Git_Exception $e): string + { + $map = [ + Tiger_Git_Exception::CREDENTIAL_INVALID => 'git.error.credential_invalid', + Tiger_Git_Exception::CREDENTIAL_MISSING => 'git.error.credential_missing', + Tiger_Git_Exception::NON_FAST_FORWARD => 'git.error.non_fast_forward', + Tiger_Git_Exception::RATE_LIMITED => 'git.error.rate_limited', + Tiger_Git_Exception::HOST_NOT_ALLOWED => 'git.error.host_not_allowed', + Tiger_Git_Exception::INVALID_REF => 'git.error.invalid_ref', + Tiger_Git_Exception::NOT_CONNECTED => 'git.error.not_connected', + Tiger_Git_Exception::PROTECTED_BRANCH => 'git.error.protected_branch', + Tiger_Git_Exception::DIRTY_TREE => 'git.error.dirty_tree', + Tiger_Git_Exception::NOTHING_TO_COMMIT => 'git.error.nothing_to_commit', + Tiger_Git_Exception::TRANSPORT_UNAVAILABLE => 'git.error.transport_unavailable', + Tiger_Git_Exception::NOT_IMPLEMENTED => 'git.error.not_implemented', + ]; + return $map[$e->code()] ?? 'git.error.general'; + } +} diff --git a/modules/git/services/Release.php b/modules/git/services/Release.php new file mode 100644 index 00000000..091caaa0 --- /dev/null +++ b/modules/git/services/Release.php @@ -0,0 +1,161 @@ +_signedIn()) { return; } + if (!$this->_isAllowed(Tiger_Git::PRIV_TAG, Tiger_Git::RESOURCE_RELEASE)) { $this->_error('git.error.not_allowed'); return; } + + $moduleKey = $this->_slug((string) ($params['module_key'] ?? '')); + if ($moduleKey === null) { $this->_error('git.error.invalid_module'); return; } + $version = trim((string) ($params['version'] ?? '')); + if (!Tiger_Git::isSemver($version)) { $this->_error('git.error.invalid_version'); return; } + + $conn = $this->_connection($moduleKey); + if (!$conn) { $this->_error('git.error.not_connected'); return; } + + $existing = (new Tiger_Model_ModuleRelease())->byTag($this->_org_id, $moduleKey, $version); + if ($existing && (string) $existing->status !== Tiger_Model_ModuleRelease::STATUS_WITHDRAWN) { + $this->_error('git.error.tag_exists'); return; // a submitted tag is immutable + } + + try { + $out = $this->_adapter()->tag($this->_tree($conn), $version); + } catch (Tiger_Git_Exception $e) { + $this->_log($moduleKey, 'tag', 'error', ['error_code' => $e->code()]); + $this->_error($this->_gitErrorKey($e)); return; + } + $this->_log($moduleKey, 'tag', 'ok', ['ref_after' => (string) ($out['sha'] ?? '')]); + $this->_success(['tag' => (string) $out['tag'], 'sha' => (string) $out['sha']], 'git.release.tagged'); + } + + /** + * Submit a tagged release to QA (owner-only). Records a `submitted` module_release row. + * + * @param array $params module_key, version, commit_sha, qa_report_ref? + * @apiRequest Git_Form_Submit + */ + public function submit(array $params): void + { + if (!$this->_signedIn()) { return; } + if (!$this->_isAllowed(Tiger_Git::PRIV_SUBMIT, Tiger_Git::RESOURCE_RELEASE)) { $this->_error('git.error.not_allowed'); return; } + + $form = new Git_Form_Submit(); + if (!$form->isValid($params)) { $this->_formErrors($form); return; } + $v = $form->getValues(); + + $moduleKey = $this->_slug((string) $v['module_key']); + if ($moduleKey === null) { $this->_error('git.error.invalid_module'); return; } + $version = trim((string) $v['version']); + if (!Tiger_Git::isSemver($version)) { $this->_error('git.error.invalid_version'); return; } + + $conn = $this->_connection($moduleKey); + if (!$conn) { $this->_error('git.error.not_connected'); return; } + + $existing = (new Tiger_Model_ModuleRelease())->byTag($this->_org_id, $moduleKey, $version); + if ($existing && in_array((string) $existing->status, [ + Tiger_Model_ModuleRelease::STATUS_SUBMITTED, + Tiger_Model_ModuleRelease::STATUS_IN_QA, + Tiger_Model_ModuleRelease::STATUS_APPROVED, + ], true)) { + $this->_error('git.error.release_exists'); return; + } + + $sha = strtolower(trim((string) $v['commit_sha'])); + $qa = trim((string) ($v['qa_report_ref'] ?? '')); + $id = $this->_transaction(function () use ($moduleKey, $version, $sha, $qa) { + return (new Tiger_Model_ModuleRelease())->insert([ + 'org_id' => (string) $this->_org_id, + 'module_key' => $moduleKey, + 'version' => $version, + 'git_tag' => $version, + 'commit_sha' => $sha, + 'qa_report_ref' => $qa !== '' ? $qa : null, + 'submitted_at' => gmdate('Y-m-d H:i:s'), + 'status' => Tiger_Model_ModuleRelease::STATUS_SUBMITTED, + ]); + }); + $this->_log($moduleKey, 'submit', 'ok', ['ref_after' => $sha]); + $this->_success(['module_release_id' => (string) $id, 'version' => $version, 'status' => Tiger_Model_ModuleRelease::STATUS_SUBMITTED], 'git.release.submitted'); + } + + /** + * Approve or reject a submitted release (the QA reviewer — admin). Moves it to approved|rejected. + * + * @param array $params module_release_id, decision (approve|reject) + */ + public function decide(array $params): void + { + if (!$this->_signedIn() || !$this->_isAllowed('decide')) { $this->_error('core.api.error.not_allowed'); return; } + + $id = (string) ($params['module_release_id'] ?? ''); + $decision = (string) ($params['decision'] ?? ''); + if (!in_array($decision, ['approve', 'reject'], true)) { $this->_error('git.error.invalid_decision'); return; } + + $model = new Tiger_Model_ModuleRelease(); + $row = $id !== '' ? $model->findById($id) : null; + if (!$row || (string) $row->org_id !== (string) $this->_org_id) { $this->_error('git.error.release_not_found'); return; } + if (!in_array((string) $row->status, [Tiger_Model_ModuleRelease::STATUS_SUBMITTED, Tiger_Model_ModuleRelease::STATUS_IN_QA], true)) { + $this->_error('git.error.release_decided'); return; + } + + $new = $decision === 'approve' ? Tiger_Model_ModuleRelease::STATUS_APPROVED : Tiger_Model_ModuleRelease::STATUS_REJECTED; + $this->_transaction(function () use ($row, $new) { + $row->status = $new; + $row->decided_at = gmdate('Y-m-d H:i:s'); + $row->save(); + }); + $this->_log((string) $row->module_key, 'release.' . $decision, 'ok', ['ref_after' => (string) $row->commit_sha]); + $this->_success(['module_release_id' => $id, 'status' => $new], 'git.release.decided'); + } + + /** + * Every release for a module, newest first (any status). + * + * @param array $params module_key + */ + public function list(array $params): void + { + if (!$this->_signedIn() || !$this->_isAllowed('list')) { $this->_error('core.api.error.not_allowed'); return; } + $moduleKey = $this->_slug((string) ($params['module_key'] ?? '')); + if ($moduleKey === null) { $this->_error('git.error.invalid_module'); return; } + + $out = []; + foreach ((new Tiger_Model_ModuleRelease())->forModule($this->_org_id, $moduleKey) as $r) { + $out[] = [ + 'module_release_id' => (string) $r->module_release_id, + 'version' => (string) $r->version, + 'git_tag' => (string) $r->git_tag, + 'commit_sha' => (string) $r->commit_sha, + 'status' => (string) $r->status, + 'qa_report_ref' => $r->qa_report_ref !== null ? (string) $r->qa_report_ref : null, + 'submitted_at' => $r->submitted_at !== null ? (string) $r->submitted_at : null, + 'decided_at' => $r->decided_at !== null ? (string) $r->decided_at : null, + 'created_at' => (string) $r->created_at, + ]; + } + $this->_success(['module_key' => $moduleKey, 'releases' => $out]); + } +} diff --git a/modules/git/services/Repo.php b/modules/git/services/Repo.php new file mode 100644 index 00000000..11a5f64d --- /dev/null +++ b/modules/git/services/Repo.php @@ -0,0 +1,237 @@ +_signedIn() || !$this->_isAllowed('connect')) { $this->_error('core.api.error.not_allowed'); return; } + + $form = new Git_Form_Connect(); + if (!$form->isValid($params)) { $this->_formErrors($form); return; } + $v = $form->getValues(); + + $moduleKey = $this->_slug((string) $v['module_key']); + if ($moduleKey === null) { $this->_error('git.error.invalid_module'); return; } + if (Tiger_Git::repoSlug($v['repo_owner'], $v['repo_name']) === null) { $this->_error('git.error.invalid_repo'); return; } + $dir = $this->_moduleDir($moduleKey); + if ($dir === null) { $this->_error('git.error.module_missing'); return; } + + $branch = trim((string) ($v['default_branch'] ?? '')) !== '' ? trim((string) $v['default_branch']) : 'main'; + $writeEnabled = !empty($v['write_enabled']); + $token = (string) $v['token']; + + // 1. store the credential so a transient tree can resolve it for verification. + try { + $cred = new Tiger_Git_Credential(); + $stored = $cred->store($this->_org_id, $moduleKey, Tiger_Model_GitCredential::TYPE_TOKEN, $token, ['scopes' => 'contents:write']); + } catch (Tiger_Git_Exception $e) { + $this->_error($this->_gitErrorKey($e)); return; + } + $ref = (string) $stored['ref']; + + // 2. detect the transport + verify the token/repo with a cheap authenticated GET. + $env = Tiger_Git_Environment::detect(); + $transport = $env['transport'] !== '' ? $env['transport'] : Tiger_Git::TRANSPORT_API; + $tree = new Tiger_Git_WorkingTree($dir, (string) $v['repo_owner'], (string) $v['repo_name'], $branch, $branch, $transport, $ref, $writeEnabled); + try { + $this->_adapter()->listReleases($tree); // ALWAYS a remote authenticated GET (the adapter routes releases to the API) — proves the token + repo + } catch (Tiger_Git_Exception $e) { + $cred->revoke($ref); + $this->_log($moduleKey, 'connect', 'error', ['error_code' => $e->code()]); + $this->_error($this->_gitErrorKey($e)); return; + } + + // 3. record the connection (reconnect reuses the active-only unique; revoke the superseded secret). + try { + $this->_transaction(function () use ($moduleKey, $v, $branch, $transport, $writeEnabled, $ref, $cred) { + $model = new Tiger_Model_GitRepoConnection(); + $existing = $model->forModule($this->_org_id, $moduleKey); + $fields = [ + 'org_id' => (string) $this->_org_id, + 'module_key' => $moduleKey, + 'provider' => Tiger_Git_Provider_GitHub::PROVIDER, + 'repo_owner' => (string) $v['repo_owner'], + 'repo_name' => (string) $v['repo_name'], + 'default_branch' => $branch, + 'transport' => $transport, + 'write_enabled' => $writeEnabled ? 1 : 0, + 'credential_ref' => $ref, + 'status' => Tiger_Git::STATUS_CONNECTED, + 'last_verified_at' => gmdate('Y-m-d H:i:s'), + ]; + if ($existing) { + $oldRef = (string) $existing->credential_ref; + foreach ($fields as $k => $val) { $existing->$k = $val; } + $existing->save(); + if ($oldRef !== '' && $oldRef !== $ref) { $cred->revoke($oldRef); } + } else { + $model->insert($fields); + } + }); + } catch (\Throwable $e) { + $cred->revoke($ref); + Tiger_Log::error('git.repo.connect.failed', ['error' => $e->getMessage()]); + $this->_error('git.error.general'); return; + } + + $conn = $this->_connection($moduleKey); + $this->_log($moduleKey, 'connect', 'ok'); + $this->_success(['connection' => $this->_connView($conn), 'environment' => $this->_envView($env)], 'git.repo.connected'); + } + + /** + * A module's working-tree status. Returns the connection + environment always; the live `working` + * status (branch/ahead/behind/changes) is present on a transport that supports it (shell), and + * `status_available=false` on one that does not yet (the API — the reconciler is later work). + * + * @param array $params module_key + */ + public function status(array $params): void + { + if (!$this->_signedIn() || !$this->_isAllowed('status')) { $this->_error('core.api.error.not_allowed'); return; } + $moduleKey = $this->_slug((string) ($params['module_key'] ?? '')); + if ($moduleKey === null) { $this->_error('git.error.invalid_module'); return; } + $conn = $this->_connection($moduleKey); + if (!$conn) { $this->_error('git.error.not_connected'); return; } + + $data = ['connection' => $this->_connView($conn), 'environment' => $this->_envView(Tiger_Git_Environment::detect())]; + try { + $data['working'] = $this->_adapter()->status($this->_tree($conn)); + $data['status_available'] = true; + } catch (Tiger_Git_Exception $e) { + if ($e->code() === Tiger_Git_Exception::NOT_IMPLEMENTED) { + $data['working'] = null; + $data['status_available'] = false; // expected on the API transport — not an error + } else { + $this->_error($this->_gitErrorKey($e)); return; + } + } + $this->_success($data); + } + + /** + * Re-detect the host's git capabilities and re-verify the connection, updating the stored transport + * + status. The "Re-check environment" action behind the connection screen. + * + * @param array $params module_key + */ + public function recheck(array $params): void + { + if (!$this->_signedIn() || !$this->_isAllowed('recheck')) { $this->_error('core.api.error.not_allowed'); return; } + $moduleKey = $this->_slug((string) ($params['module_key'] ?? '')); + if ($moduleKey === null) { $this->_error('git.error.invalid_module'); return; } + $conn = $this->_connection($moduleKey); + if (!$conn) { $this->_error('git.error.not_connected'); return; } + + $env = Tiger_Git_Environment::detect(); + $transport = $env['transport'] !== '' ? $env['transport'] : (string) $conn->transport; + $conn->transport = $transport; // verify against the freshly detected transport + + $status = Tiger_Git::STATUS_CONNECTED; + try { + $this->_adapter()->listReleases($this->_tree($conn)); // remote auth check (always the API) + } catch (Tiger_Git_Exception $e) { + $status = Tiger_Git::STATUS_DEGRADED; + } + + $this->_transaction(function () use ($conn, $status) { + $conn->status = $status; + $conn->last_verified_at = gmdate('Y-m-d H:i:s'); + $conn->save(); // transport already set above + }); + $this->_log($moduleKey, 'recheck', $status === Tiger_Git::STATUS_CONNECTED ? 'ok' : 'error'); + + $fresh = $this->_connection($moduleKey); + $this->_success(['connection' => $this->_connView($fresh), 'environment' => $this->_envView($env)]); + } + + /** + * Every connection in the caller's org (the connections list). + * + * @param array $params (none) + */ + public function list(array $params): void + { + if (!$this->_signedIn() || !$this->_isAllowed('list')) { $this->_error('core.api.error.not_allowed'); return; } + $rows = (new Tiger_Model_GitRepoConnection())->forOrg($this->_org_id); + $this->_success(['connections' => array_map([$this, '_connView'], iterator_to_array($rows))]); + } + + /** + * The recent audit trail for a module (who did what to the repo). + * + * @param array $params module_key, limit? + */ + public function log(array $params): void + { + if (!$this->_signedIn() || !$this->_isAllowed('log')) { $this->_error('core.api.error.not_allowed'); return; } + $moduleKey = $this->_slug((string) ($params['module_key'] ?? '')); + if ($moduleKey === null) { $this->_error('git.error.invalid_module'); return; } + $limit = min(200, max(1, (int) ($params['limit'] ?? 50))); + $rows = (new Tiger_Model_GitOperationLog())->recentForModule($this->_org_id, $moduleKey, $limit); + + $entries = []; + foreach ($rows as $r) { + $entries[] = [ + 'operation' => (string) $r->operation, + 'actor_type' => (string) $r->actor_type, + 'result' => (string) $r->result, + 'branch' => $r->branch !== null ? (string) $r->branch : null, + 'ref_after' => $r->ref_after !== null ? (string) $r->ref_after : null, + 'error_code' => $r->error_code !== null ? (string) $r->error_code : null, + 'created_at' => (string) $r->created_at, + ]; + } + $this->_success(['module_key' => $moduleKey, 'entries' => $entries]); + } + + /** + * Disconnect a module: soft-delete the connection (freeing the active-only unique for a clean + * reconnect) and revoke its credential. + * + * @param array $params module_key + */ + public function disconnect(array $params): void + { + if (!$this->_signedIn() || !$this->_isAllowed('disconnect')) { $this->_error('core.api.error.not_allowed'); return; } + $moduleKey = $this->_slug((string) ($params['module_key'] ?? '')); + if ($moduleKey === null) { $this->_error('git.error.invalid_module'); return; } + $conn = $this->_connection($moduleKey); + if (!$conn) { $this->_error('git.error.not_connected'); return; } + + $ref = (string) $conn->credential_ref; + $this->_transaction(function () use ($conn, $ref) { + $model = new Tiger_Model_GitRepoConnection(); + $model->softDelete($model->getAdapter()->quoteInto('git_repo_connection_id = ?', (string) $conn->git_repo_connection_id)); + if ($ref !== '') { (new Tiger_Git_Credential())->revoke($ref); } + }); + $this->_log($moduleKey, 'disconnect', 'ok'); + $this->_success(['module_key' => $moduleKey], 'git.repo.disconnected'); + } +} diff --git a/modules/git/services/Update.php b/modules/git/services/Update.php new file mode 100644 index 00000000..51389e0d --- /dev/null +++ b/modules/git/services/Update.php @@ -0,0 +1,236 @@ +_signedIn() || !$this->_isAllowed('check')) { $this->_error('core.api.error.not_allowed'); return; } + + $only = isset($params['module_key']) ? $this->_slug((string) $params['module_key']) : null; + if (isset($params['module_key']) && $only === null) { $this->_error('git.error.invalid_module'); return; } + + $connections = (new Tiger_Model_GitRepoConnection())->forOrg($this->_org_id); + $pinModel = new Tiger_Model_ModuleInstallPin(); + $relModel = new Tiger_Model_ModuleRelease(); + + $updates = []; + foreach ($connections as $conn) { + $moduleKey = (string) $conn->module_key; + if ($only !== null && $moduleKey !== $only) { continue; } + + $pin = $pinModel->forModule($this->_org_id, $moduleKey); + $installed = $pin && $pin->installed_version !== null ? (string) $pin->installed_version : null; + $channel = $pin ? (string) $pin->channel : Tiger_Git::POLICY_NOTIFY; + + $approved = $relModel->approvedForModule($this->_org_id, $moduleKey); + $latest = count($approved) ? (string) $approved->current()->version : null; + + $pending = $latest !== null + && $channel !== Tiger_Git::POLICY_PINNED + && ($installed === null || version_compare(Tiger_Git::normalizeVersion($latest), Tiger_Git::normalizeVersion($installed), '>')); + + $updates[] = [ + 'module_key' => $moduleKey, + 'installed_version' => $installed, + 'latest_approved_version'=> $latest, + 'channel' => $channel, + 'pending' => $pending, + ]; + } + $this->_success(['updates' => $updates]); + } + + /** + * Apply an approved update to a module — download the tag's archive and update in place. Defaults to + * the latest approved release; a specific `version` must also be approved. Records the rollback anchor. + * + * @param array $params module_key, version? + */ + public function apply(array $params): void + { + if (!$this->_signedIn() || !$this->_isAllowed('apply')) { $this->_error('core.api.error.not_allowed'); return; } + + $moduleKey = $this->_slug((string) ($params['module_key'] ?? '')); + if ($moduleKey === null) { $this->_error('git.error.invalid_module'); return; } + $conn = $this->_connection($moduleKey); + if (!$conn) { $this->_error('git.error.not_connected'); return; } + + // Resolve the target to an APPROVED release (never an arbitrary version). + $relModel = new Tiger_Model_ModuleRelease(); + $wanted = trim((string) ($params['version'] ?? '')); + $release = null; + if ($wanted !== '') { + $row = $relModel->byTag($this->_org_id, $moduleKey, $wanted); + if ($row && (string) $row->status === Tiger_Model_ModuleRelease::STATUS_APPROVED) { $release = $row; } + } else { + $approved = $relModel->approvedForModule($this->_org_id, $moduleKey); + if (count($approved)) { $release = $approved->current(); } + } + if (!$release) { $this->_error($wanted !== '' ? 'git.error.not_approved' : 'git.error.no_update'); return; } + + $target = (string) $release->version; + + // A rollback pins the bad version; refuse to reapply exactly that one. + $pinModel = new Tiger_Model_ModuleInstallPin(); + $pin = $pinModel->forModule($this->_org_id, $moduleKey); + if ($pin && (string) $pin->channel === Tiger_Git::POLICY_PINNED + && $pin->pinned_version !== null && (string) $pin->pinned_version === $target) { + $this->_error('git.error.version_pinned'); return; + } + $installed = $pin && $pin->installed_version !== null ? (string) $pin->installed_version : null; + if ($installed !== null && version_compare(Tiger_Git::normalizeVersion($target), Tiger_Git::normalizeVersion($installed), '==')) { + $this->_error('git.error.no_update'); return; // already on it + } + + $applied = $this->_download($conn, $moduleKey, (string) $release->git_tag, (string) $release->commit_sha, 'update.apply'); + if ($applied === false) { return; } // _download set the error + + $this->_transaction(function () use ($pinModel, $pin, $moduleKey, $target, $installed) { + $this->_savePin($pinModel, $pin, $moduleKey, [ + 'installed_version' => $target, + 'previous_version' => $installed, + ]); + }); + $this->_success(['module_key' => $moduleKey, 'version' => $target, 'previous_version' => $installed], 'git.update.applied'); + } + + /** + * Set a module's update policy: auto | notify | pinned (+ an optional pinned_version). + * + * @param array $params module_key, channel, pinned_version? + */ + public function setPolicy(array $params): void + { + if (!$this->_signedIn() || !$this->_isAllowed('setPolicy')) { $this->_error('core.api.error.not_allowed'); return; } + + $moduleKey = $this->_slug((string) ($params['module_key'] ?? '')); + if ($moduleKey === null) { $this->_error('git.error.invalid_module'); return; } + $channel = (string) ($params['channel'] ?? ''); + if (!in_array($channel, [Tiger_Git::POLICY_AUTO, Tiger_Git::POLICY_NOTIFY, Tiger_Git::POLICY_PINNED], true)) { + $this->_error('git.error.invalid_channel'); return; + } + $pinnedVersion = trim((string) ($params['pinned_version'] ?? '')); + + $pinModel = new Tiger_Model_ModuleInstallPin(); + $pin = $pinModel->forModule($this->_org_id, $moduleKey); + $fields = ['channel' => $channel]; + if ($channel === Tiger_Git::POLICY_PINNED && $pinnedVersion !== '') { $fields['pinned_version'] = $pinnedVersion; } + + $this->_transaction(function () use ($pinModel, $pin, $moduleKey, $fields) { + $this->_savePin($pinModel, $pin, $moduleKey, $fields); + }); + $this->_log($moduleKey, 'update.policy', 'ok'); + $this->_success(['pin' => $this->_pinView($pinModel->forModule($this->_org_id, $moduleKey))], 'git.update.policy_set'); + } + + /** + * Roll a module back to its previous version (which must still be an approved release). Pins the + * version it fled so auto-update does not immediately reapply it. + * + * @param array $params module_key + */ + public function rollback(array $params): void + { + if (!$this->_signedIn() || !$this->_isAllowed('rollback')) { $this->_error('core.api.error.not_allowed'); return; } + + $moduleKey = $this->_slug((string) ($params['module_key'] ?? '')); + if ($moduleKey === null) { $this->_error('git.error.invalid_module'); return; } + $conn = $this->_connection($moduleKey); + if (!$conn) { $this->_error('git.error.not_connected'); return; } + + $pinModel = new Tiger_Model_ModuleInstallPin(); + $pin = $pinModel->forModule($this->_org_id, $moduleKey); + $previous = $pin && $pin->previous_version !== null ? (string) $pin->previous_version : null; + $current = $pin && $pin->installed_version !== null ? (string) $pin->installed_version : null; + if ($previous === null) { $this->_error('git.error.no_previous'); return; } + + $release = (new Tiger_Model_ModuleRelease())->byTag($this->_org_id, $moduleKey, $previous); + if (!$release || (string) $release->status !== Tiger_Model_ModuleRelease::STATUS_APPROVED) { + $this->_error('git.error.not_approved'); return; + } + + $applied = $this->_download($conn, $moduleKey, (string) $release->git_tag, (string) $release->commit_sha, 'update.rollback'); + if ($applied === false) { return; } + + $this->_transaction(function () use ($pinModel, $pin, $moduleKey, $previous, $current) { + $this->_savePin($pinModel, $pin, $moduleKey, [ + 'installed_version' => $previous, + 'previous_version' => null, + 'channel' => Tiger_Git::POLICY_PINNED, // don't auto-reapply the bad one + 'pinned_version' => $current, // the version we fled + ]); + }); + $this->_success(['module_key' => $moduleKey, 'version' => $previous, 'pinned_version' => $current], 'git.update.rolled_back'); + } + + // --- internals ------------------------------------------------------------------------------- + + /** + * Download an approved tag's archive and update the module in place. Returns true, or false after + * setting a mapped error (the caller returns on false). Shared by apply + rollback. + */ + private function _download($conn, string $moduleKey, string $tag, string $sha, string $operation): bool + { + $tmp = tempnam(sys_get_temp_dir(), 'tigergit') . '.tar.gz'; + try { + $this->_adapter()->fetchArchive($this->_tree($conn), $tag, $tmp); + $this->_installTarball($tmp, [ + 'repository' => $conn->repo_owner . '/' . $conn->repo_name, + 'ref' => $tag, + 'source' => Tiger_Model_Module::SOURCE_URL, + ], ['force' => true]); + } catch (Tiger_Git_Exception $e) { + $this->_log($moduleKey, $operation, 'error', ['error_code' => $e->code()]); + $this->_error($this->_gitErrorKey($e)); + return false; + } catch (\Throwable $e) { + Tiger_Log::error('git.update.install_failed', ['module' => $moduleKey, 'error' => $e->getMessage()]); + $this->_log($moduleKey, $operation, 'error', ['error_code' => 'install']); + $this->_error('git.error.install_failed'); + return false; + } finally { + if (is_file($tmp)) { @unlink($tmp); } + } + $this->_log($moduleKey, $operation, 'ok', ['ref_after' => $sha]); + return true; + } + + /** Insert or update the module_install_pin row with the given fields. */ + private function _savePin(Tiger_Model_ModuleInstallPin $model, $pin, string $moduleKey, array $fields): void + { + if ($pin) { + foreach ($fields as $k => $v) { $pin->$k = $v; } + $pin->save(); + } else { + $model->insert(array_merge([ + 'org_id' => (string) $this->_org_id, + 'module_key' => $moduleKey, + 'channel' => Tiger_Git::POLICY_NOTIFY, + 'status' => 'active', + ], $fields)); + } + } +} diff --git a/modules/git/views/scripts/admin/index.phtml b/modules/git/views/scripts/admin/index.phtml new file mode 100644 index 00000000..e637a1cd --- /dev/null +++ b/modules/git/views/scripts/admin/index.phtml @@ -0,0 +1,140 @@ +/