From 1ea3f95173d71e4c49e3dd64d19312732a4e1ed7 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 21 Oct 2026 13:07:16 -0400 Subject: [PATCH 01/10] =?UTF-8?q?feat(git):=20Tiger=5FGit=20Phase=201=20fo?= =?UTF-8?q?undation=20=E2=80=94=20interfaces,=20env=20detection,=20credent?= =?UTF-8?q?ials,=20data=20model=20(TIGER-334)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Epic TIGER-334's Phase-1 substrate for the Tiger_Git module: version a custom module in a private repo, cut a release through QA, and receive approved updates — no local machine, no terminal, and (on shared hosting with exec disabled) no git binary. Ships the foundation only; the concrete transports + /api services + TigerPanel UI are Phase 2+. - Tiger_Git (facade): constants, semver/host-allowlist/repo-slug validators, RESOURCE_RELEASE capability resource (owner-only tag/submit, per TIGER-335 — never a role-string compare). - Tiger_Git_Transport_Interface: the one contract ShellTransport + ApiTransport implement with equivalent results; fast-forward-only, allowlisted-host-only, structured secret-free failures. - Tiger_Git_Environment (TIGER-363): capability detection (git binary + exec usable + outbound) → recommended transport; ApiTransport needs only outbound. Network probe behind a test seam. - Tiger_Git_Credential (TIGER-365): write-only credential manager — encrypt-at-rest via Tiger_Crypto, resolve-for-transport-only (no controller/log path), rotate (new before old revoked), revoke, and an Ed25519 deploy-key generator. Supports a Tiger_Secret ref too. - Data model (TIGER-366, migration 20261021000001): git_repo_connection, git_credential, git_operation_log (append-only audit), module_release, module_install_pin — standard columns, ACTIVE-ONLY unique indexes so a reconnect after disconnect is never blocked. - Tiger_Git_WorkingTree + Tiger_Git_Exception value objects; module scaffold (module.json, Bootstrap, acl.ini contract stub); CAPABILITIES.md regenerated. Tests: 19 unit + 12 integration (proven against real MariaDB) — path-escape refusal, env decision logic, keygen framing, credential write-only/round-trip/rotate/revoke, active-only unique freeing on soft-delete, and the model finders. Full unit suite stays green (1150). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ASauLLscjqdsNqBNsx2Typ --- CAPABILITIES.md | 14 +- library/Tiger/Git.php | 112 ++++++++++ library/Tiger/Git/Credential.php | 157 ++++++++++++++ library/Tiger/Git/Environment.php | 120 +++++++++++ library/Tiger/Git/Exception.php | 63 ++++++ library/Tiger/Git/Transport/Interface.php | 100 +++++++++ library/Tiger/Git/WorkingTree.php | 112 ++++++++++ library/Tiger/Model/GitCredential.php | 53 +++++ library/Tiger/Model/GitOperationLog.php | 59 ++++++ library/Tiger/Model/GitRepoConnection.php | 36 ++++ library/Tiger/Model/ModuleInstallPin.php | 37 ++++ library/Tiger/Model/ModuleRelease.php | 47 +++++ modules/git/Bootstrap.php | 19 ++ modules/git/configs/acl.ini | 27 +++ .../20261021000001_create_git_tables.php | 133 ++++++++++++ modules/git/module.json | 5 + tests/Integration/Git/GitDataTest.php | 194 ++++++++++++++++++ tests/Unit/Git/CredentialKeygenTest.php | 53 +++++ tests/Unit/Git/EnvironmentTest.php | 67 ++++++ tests/Unit/Git/GitTest.php | 65 ++++++ tests/Unit/Git/WorkingTreeTest.php | 107 ++++++++++ 21 files changed, 1579 insertions(+), 1 deletion(-) create mode 100644 library/Tiger/Git.php create mode 100644 library/Tiger/Git/Credential.php create mode 100644 library/Tiger/Git/Environment.php create mode 100644 library/Tiger/Git/Exception.php create mode 100644 library/Tiger/Git/Transport/Interface.php create mode 100644 library/Tiger/Git/WorkingTree.php create mode 100644 library/Tiger/Model/GitCredential.php create mode 100644 library/Tiger/Model/GitOperationLog.php create mode 100644 library/Tiger/Model/GitRepoConnection.php create mode 100644 library/Tiger/Model/ModuleInstallPin.php create mode 100644 library/Tiger/Model/ModuleRelease.php create mode 100644 modules/git/Bootstrap.php create mode 100644 modules/git/configs/acl.ini create mode 100644 modules/git/migrations/20261021000001_create_git_tables.php create mode 100644 modules/git/module.json create mode 100644 tests/Integration/Git/GitDataTest.php create mode 100644 tests/Unit/Git/CredentialKeygenTest.php create mode 100644 tests/Unit/Git/EnvironmentTest.php create mode 100644 tests/Unit/Git/GitTest.php create mode 100644 tests/Unit/Git/WorkingTreeTest.php diff --git a/CAPABILITIES.md b/CAPABILITIES.md index ab70f0a6..37171046 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). +**240 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,12 @@ - **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_Transport_Interface** `@api` — HOW a git operation is carried out (TIGER-334). · `library/Tiger/Git/Transport/Interface.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 +359,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` +- **Git** (`git`, app) · `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/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..e0c2e580 --- /dev/null +++ b/library/Tiger/Git/Exception.php @@ -0,0 +1,63 @@ + 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/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/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/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..7795df92 --- /dev/null +++ b/library/Tiger/Model/ModuleRelease.php @@ -0,0 +1,47 @@ +fetchAll( + $this->activeSelect() + ->where('org_id = ?', (string) $orgId) + ->where('module_key = ?', (string) $moduleKey) + ->where('status = ?', self::STATUS_APPROVED) + ->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/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 @@ + [ + // --- 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..9f5af37d --- /dev/null +++ b/modules/git/module.json @@ -0,0 +1,5 @@ +{ + "name": "Git", + "type": "app", + "category": ["developer"] +} diff --git a/tests/Integration/Git/GitDataTest.php b/tests/Integration/Git/GitDataTest.php new file mode 100644 index 00000000..5deb7b0e --- /dev/null +++ b/tests/Integration/Git/GitDataTest.php @@ -0,0 +1,194 @@ +db->rollBack(); // close the harness per-test txn; DDL would auto-commit it anyway + if (!$this->tableExists('git_repo_connection')) { + $m = require dirname(__DIR__, 3) . '/modules/git/migrations/20261021000001_create_git_tables.php'; + foreach ($m['up'] as $sql) { $this->db->query($sql); } + } + $this->db->beginTransaction(); // re-open so THIS test body still rolls back + self::$gitSchema = true; + } + + // Seed a crypto key so Tiger_Crypto::isConfigured() is true (the AgentMcpTest idiom). + $cfg = \Zend_Registry::isRegistered('Zend_Config') ? \Zend_Registry::get('Zend_Config') : new \Zend_Config([], true); + if ($cfg->readOnly()) { $cfg = new \Zend_Config($cfg->toArray(), true); } + $cfg->merge(new \Zend_Config(['tiger' => ['crypto' => ['key' => base64_encode(str_repeat("\x22", 32))]]], true)); + \Zend_Registry::set('Zend_Config', $cfg); + } + + #[Test] + public function the_five_tables_build(): void + { + foreach (['git_repo_connection', 'git_credential', 'git_operation_log', 'module_release', 'module_install_pin'] as $t) { + $this->assertTrue($this->tableExists($t), "expected git table `$t`"); + } + } + + // --- credential: write-only -------------------------------------------------------------- + + #[Test] + public function store_returns_no_secret_and_resolve_round_trips(): void + { + $cred = new Tiger_Git_Credential(); + $out = $cred->store('o1', 'acme/mod', Tiger_Model_GitCredential::TYPE_TOKEN, 'tgr_the_secret', ['fingerprint' => 'fp1', 'scopes' => 'contents:read']); + + $this->assertArrayHasKey('ref', $out); + $flat = json_encode($out['public']); + $this->assertStringNotContainsString('tgr_the_secret', $flat, 'the public view must never carry the secret'); + $this->assertSame('fp1', $out['public']['fingerprint']); + + $this->assertSame('tgr_the_secret', $cred->resolve($out['ref']), 'the transport read path recovers the secret'); + } + + #[Test] + public function resolve_of_an_unknown_ref_is_null_not_a_throw(): void + { + $this->assertNull((new Tiger_Git_Credential())->resolve('00000000-0000-0000-0000-000000000000')); + $this->assertNull((new Tiger_Git_Credential())->resolve('')); + } + + #[Test] + public function revoke_stops_the_secret_resolving(): void + { + $cred = new Tiger_Git_Credential(); + $ref = $cred->store('o1', 'acme/mod', Tiger_Model_GitCredential::TYPE_TOKEN, 's3cret')['ref']; + + $this->assertTrue($cred->revoke($ref)); + $this->assertNull($cred->resolve($ref), 'a revoked credential no longer resolves'); + $this->assertNull((new Tiger_Model_GitCredential())->findById($ref), 'the row is soft-deleted'); + } + + #[Test] + public function an_empty_secret_is_refused(): void + { + $this->expectException(Tiger_Git_Exception::class); + (new Tiger_Git_Credential())->store('o1', 'acme/mod', Tiger_Model_GitCredential::TYPE_TOKEN, ''); + } + + #[Test] + public function rotate_stores_the_new_without_revoking_the_old(): void + { + $cred = new Tiger_Git_Credential(); + $old = $cred->store('o1', 'acme/mod', Tiger_Model_GitCredential::TYPE_TOKEN, 'old')['ref']; + $new = $cred->rotate('o1', 'acme/mod', Tiger_Model_GitCredential::TYPE_TOKEN, 'new')['ref']; + + $this->assertNotSame($old, $new); + $this->assertSame('old', $cred->resolve($old), 'the old credential survives until verification passes'); + $this->assertSame('new', $cred->resolve($new)); + } + + #[Test] + public function a_tiger_secret_ref_is_not_ours_to_revoke(): void + { + $this->assertFalse((new Tiger_Git_Credential())->revoke('ssm://tiger/prod/node/ses_key')); + } + + // --- connection: active-only unique + finders -------------------------------------------- + + #[Test] + public function reconnect_after_disconnect_is_not_blocked_by_the_unique(): void + { + $m = new Tiger_Model_GitRepoConnection(); + $row = ['org_id' => 'o1', 'module_key' => 'acme/mod', 'repo_owner' => 'acme', 'repo_name' => 'mod', 'default_branch' => 'main', 'transport' => 'api']; + + $first = $m->insert($row); + $m->softDelete($m->getAdapter()->quoteInto('git_repo_connection_id = ?', $first)); + + // Same (org_id, module_key) again — a plain UNIQUE would throw here; the active-only one frees it. + $second = $m->insert($row); + $this->assertNotSame($first, $second); + + $live = $m->forModule('o1', 'acme/mod'); + $this->assertNotNull($live); + $this->assertSame($second, $live->git_repo_connection_id); + } + + #[Test] + public function repo_connection_finders(): void + { + $m = new Tiger_Model_GitRepoConnection(); + $m->insert(['org_id' => 'o2', 'module_key' => 'a/one', 'repo_owner' => 'a', 'repo_name' => 'one', 'default_branch' => 'main', 'transport' => 'api']); + $m->insert(['org_id' => 'o2', 'module_key' => 'a/two', 'repo_owner' => 'a', 'repo_name' => 'two', 'default_branch' => 'main', 'transport' => 'shell']); + + $this->assertCount(2, $m->forOrg('o2')); + $this->assertSame('shell', $m->forModule('o2', 'a/two')->transport); + $this->assertNull($m->forModule('o2', 'a/missing')); + } + + #[Test] + public function release_finders_by_tag_and_approved(): void + { + $m = new Tiger_Model_ModuleRelease(); + $m->insert(['org_id' => 'o3', 'module_key' => 'a/mod', 'version' => '1.0.0', 'git_tag' => 'v1.0.0', 'commit_sha' => 'abc', 'status' => Tiger_Model_ModuleRelease::STATUS_APPROVED]); + $m->insert(['org_id' => 'o3', 'module_key' => 'a/mod', 'version' => '1.1.0', 'git_tag' => 'v1.1.0', 'commit_sha' => 'def', 'status' => Tiger_Model_ModuleRelease::STATUS_SUBMITTED]); + + $this->assertSame('v1.0.0', $m->byTag('o3', 'a/mod', 'v1.0.0')->git_tag); + $this->assertNull($m->byTag('o3', 'a/mod', 'v9.9.9')); + $this->assertCount(1, $m->approvedForModule('o3', 'a/mod'), 'only the approved release is installable'); + } + + #[Test] + public function operation_log_records_and_reads_back(): void + { + $log = new Tiger_Model_GitOperationLog(); + $log->record(['org_id' => 'o4', 'module_key' => 'a/mod', 'operation' => 'commit', 'branch' => 'main', 'ref_after' => 'deadbeef', 'result' => Tiger_Model_GitOperationLog::RESULT_OK]); + $log->record(['org_id' => 'o4', 'module_key' => 'a/mod', 'operation' => 'push', 'result' => Tiger_Model_GitOperationLog::RESULT_ERROR, 'error_code' => Tiger_Git_Exception::NON_FAST_FORWARD]); + + $rows = $log->recentForModule('o4', 'a/mod'); + $this->assertCount(2, $rows); + $this->assertSame('push', $rows[0]->operation, 'newest first'); + } + + #[Test] + public function install_pin_finder(): void + { + $m = new Tiger_Model_ModuleInstallPin(); + $m->insert(['org_id' => 'o5', 'module_key' => 'a/mod', 'installed_version' => '1.0.0', 'channel' => 'notify']); + $this->assertSame('1.0.0', $m->forModule('o5', 'a/mod')->installed_version); + $this->assertNull($m->forModule('o5', 'a/none')); + } +} diff --git a/tests/Unit/Git/CredentialKeygenTest.php b/tests/Unit/Git/CredentialKeygenTest.php new file mode 100644 index 00000000..fee92a15 --- /dev/null +++ b/tests/Unit/Git/CredentialKeygenTest.php @@ -0,0 +1,53 @@ +generateDeployKey('tiger:my_module'); + + $this->assertStringStartsWith('ssh-ed25519 ', $key['public_openssh']); + $this->assertStringEndsWith(' tiger:my_module', $key['public_openssh']); + $this->assertStringStartsWith('SHA256:', $key['fingerprint']); + $this->assertNotSame('', $key['secret']); + + // The base64 blob between the type and the comment must decode to: string "ssh-ed25519" + a + // 32-byte public key (the OpenSSH wire format). + $b64 = explode(' ', $key['public_openssh'])[1]; + $blob = base64_decode($b64, true); + $this->assertNotFalse($blob); + + $typeLen = unpack('N', substr($blob, 0, 4))[1]; + $this->assertSame(11, $typeLen); + $this->assertSame('ssh-ed25519', substr($blob, 4, 11)); + $keyLen = unpack('N', substr($blob, 15, 4))[1]; + $this->assertSame(32, $keyLen, 'an ed25519 public key is 32 bytes'); + } + + #[Test] + public function every_keypair_is_unique(): void + { + $cred = new Tiger_Git_Credential(); + $a = $cred->generateDeployKey(); + $b = $cred->generateDeployKey(); + $this->assertNotSame($a['public_openssh'], $b['public_openssh']); + $this->assertNotSame($a['secret'], $b['secret']); + $this->assertNotSame($a['fingerprint'], $b['fingerprint']); + } +} diff --git a/tests/Unit/Git/EnvironmentTest.php b/tests/Unit/Git/EnvironmentTest.php new file mode 100644 index 00000000..14f6036d --- /dev/null +++ b/tests/Unit/Git/EnvironmentTest.php @@ -0,0 +1,67 @@ + false); + $env = Tiger_Git_Environment::detect(); + + $this->assertFalse($env['outbound']); + $this->assertFalse($env['usable']); + $this->assertSame('', $env['transport']); + $this->assertNotNull($env['remediation']); + $this->assertStringContainsString('github.com', $env['remediation']); + } + + #[Test] + public function outbound_yields_a_usable_transport(): void + { + Tiger_Git_Environment::setProbe(fn (string $h) => true); + $env = Tiger_Git_Environment::detect(); + + $this->assertTrue($env['outbound']); + $this->assertTrue($env['usable'], 'ApiTransport needs only outbound'); + $this->assertContains($env['transport'], [Tiger_Git::TRANSPORT_SHELL, Tiger_Git::TRANSPORT_API]); + $this->assertNull($env['remediation']); + } + + #[Test] + public function the_probe_refuses_a_non_allowlisted_host(): void + { + Tiger_Git_Environment::setProbe(fn (string $h) => true); // even a yes-probe can't reach off-list + $this->assertFalse(Tiger_Git_Environment::outboundReachable('evil.example.com')); + $this->assertTrue(Tiger_Git_Environment::outboundReachable('github.com')); + } + + #[Test] + public function a_throwing_probe_is_treated_as_unreachable(): void + { + Tiger_Git_Environment::setProbe(function (string $h): bool { throw new \RuntimeException('boom'); }); + $this->assertFalse(Tiger_Git_Environment::outboundReachable('github.com')); + } +} diff --git a/tests/Unit/Git/GitTest.php b/tests/Unit/Git/GitTest.php new file mode 100644 index 00000000..601f6b4d --- /dev/null +++ b/tests/Unit/Git/GitTest.php @@ -0,0 +1,65 @@ +assertTrue(Tiger_Git::isSemver('1.0.0')); + $this->assertTrue(Tiger_Git::isSemver('v2.3.1')); + $this->assertTrue(Tiger_Git::isSemver('1.0.0-beta.1')); + $this->assertTrue(Tiger_Git::isSemver('v10.20.30+build.5')); + } + + #[Test] + public function is_semver_rejects_junk_and_partial_versions(): void + { + $this->assertFalse(Tiger_Git::isSemver('1.0')); + $this->assertFalse(Tiger_Git::isSemver('latest')); + $this->assertFalse(Tiger_Git::isSemver('01.0.0')); // leading zero + $this->assertFalse(Tiger_Git::isSemver('')); + } + + #[Test] + public function normalize_version_strips_only_a_leading_v(): void + { + $this->assertSame('1.2.3', Tiger_Git::normalizeVersion('v1.2.3')); + $this->assertSame('1.2.3', Tiger_Git::normalizeVersion('1.2.3')); + $this->assertSame('1.2.3-v2', Tiger_Git::normalizeVersion('v1.2.3-v2')); // only the leading v + } + + #[Test] + public function allowed_host_is_the_github_set_only(): void + { + $this->assertTrue(Tiger_Git::isAllowedHost('github.com')); + $this->assertTrue(Tiger_Git::isAllowedHost('API.GITHUB.COM')); // case-insensitive + $this->assertTrue(Tiger_Git::isAllowedHost('codeload.github.com')); + $this->assertFalse(Tiger_Git::isAllowedHost('evil.example.com')); + $this->assertFalse(Tiger_Git::isAllowedHost('github.com.evil.com')); + } + + #[Test] + public function repo_slug_validates_or_refuses(): void + { + $this->assertSame(['WebTigers', 'tiger-core'], Tiger_Git::repoSlug('WebTigers', 'tiger-core')); + $this->assertSame(['a_b.c', 'd-e.f'], Tiger_Git::repoSlug('a_b.c', 'd-e.f')); + $this->assertNull(Tiger_Git::repoSlug('owner/evil', 'repo')); // a slash can't ride in + $this->assertNull(Tiger_Git::repoSlug('..', 'repo')); + $this->assertNull(Tiger_Git::repoSlug('owner', '')); + } +} diff --git a/tests/Unit/Git/WorkingTreeTest.php b/tests/Unit/Git/WorkingTreeTest.php new file mode 100644 index 00000000..ce600537 --- /dev/null +++ b/tests/Unit/Git/WorkingTreeTest.php @@ -0,0 +1,107 @@ +dir = sys_get_temp_dir() . '/tiger-git-wt-' . bin2hex(random_bytes(4)); + mkdir($this->dir . '/sub', 0700, true); + file_put_contents($this->dir . '/module.json', '{}'); + file_put_contents($this->dir . '/sub/x.php', 'dir . '/sub/x.php'); + @unlink($this->dir . '/module.json'); + @rmdir($this->dir . '/sub'); + @rmdir($this->dir); + parent::tearDown(); + } + + private function tree(): Tiger_Git_WorkingTree + { + return new Tiger_Git_WorkingTree($this->dir, 'WebTigers', 'tiger-core', 'main', 'main', 'api', 'cred-1', true); + } + + #[Test] + public function it_exposes_its_facts(): void + { + $t = $this->tree(); + $this->assertSame('WebTigers/tiger-core', $t->slug()); + $this->assertSame('api', $t->transport()); + $this->assertSame('cred-1', $t->credentialRef()); + $this->assertTrue($t->writeEnabled()); + $this->assertSame(realpath($this->dir), $t->dir()); + } + + #[Test] + public function resolve_accepts_an_in_tree_path(): void + { + $t = $this->tree(); + $this->assertSame(realpath($this->dir . '/sub/x.php'), $t->resolve('sub/x.php')); + } + + #[Test] + public function resolve_allows_a_not_yet_existing_in_tree_path(): void + { + $t = $this->tree(); + // The tree canonicalizes its root via realpath(); a not-yet-existing path is that root + rel. + $this->assertSame(realpath($this->dir) . '/sub/new.php', $t->resolve('sub/new.php')); + } + + #[Test] + public function resolve_refuses_a_parent_traversal(): void + { + $this->expectException(Tiger_Git_Exception::class); + $this->tree()->resolve('../escape.php'); + } + + #[Test] + public function resolve_refuses_an_absolute_path(): void + { + $this->expectException(Tiger_Git_Exception::class); + $this->tree()->resolve('/etc/passwd'); + } + + #[Test] + public function constructor_refuses_a_missing_directory(): void + { + $this->expectException(Tiger_Git_Exception::class); + new Tiger_Git_WorkingTree('/no/such/dir/' . bin2hex(random_bytes(4)), 'a', 'b', 'main', 'main', 'api'); + } + + #[Test] + public function constructor_refuses_an_invalid_slug(): void + { + $this->expectException(Tiger_Git_Exception::class); + new Tiger_Git_WorkingTree($this->dir, 'owner/evil', 'repo', 'main', 'main', 'api'); + } + + #[Test] + public function with_branch_returns_a_copy_on_the_new_branch(): void + { + $t = $this->tree()->withBranch('feature/x'); + $this->assertSame('feature/x', $t->currentBranch()); + $this->assertSame('main', $t->defaultBranch()); + } +} From 7866ee71a5b5582debf411b20472aee279eeece1 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 21 Oct 2026 13:32:54 -0400 Subject: [PATCH 02/10] chore(git): module display name "Git" -> "TigerGit" (naming consistency) Matches the TigerShop/TigerServer product-name convention. Slug (`git`), table prefix (`git_*`), and class names (Tiger_Git_* / Git_*) are unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ASauLLscjqdsNqBNsx2Typ --- modules/git/module.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/git/module.json b/modules/git/module.json index 9f5af37d..210d987f 100644 --- a/modules/git/module.json +++ b/modules/git/module.json @@ -1,5 +1,5 @@ { - "name": "Git", + "name": "TigerGit", "type": "app", "category": ["developer"] } From bc5e4188ead3ad7ccdb6b315e27c96d674990543 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 21 Oct 2026 13:39:47 -0400 Subject: [PATCH 03/10] =?UTF-8?q?feat(git):=20ApiTransport=20commit=20spik?= =?UTF-8?q?e=20=E2=80=94=20create=20commits=20via=20the=20GitHub=20API,=20?= =?UTF-8?q?no=20.git=20(TIGER-364)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 leads with the module's biggest risk: committing with no local git repo. Tiger_Git_Transport_Api implements the commit path through the GitHub Git Data API: 1. read the branch head (ref) + its base tree 2. POST a blob per changed file (base64); a null-sha entry per deleted file 3. POST a new tree (on base_tree) then a new commit (parent = the old head) 4. fast-forward the branch ref (force:false) — a branch that moved under us is reported NON_FAST_FORWARD, never clobbered With the API transport there is no local index, so commit() writes to the remote in one flow and push() is a verified no-op (committing IS pushing). Also implemented: branches(), createBranch() (refuses the default branch). status/diff/switch/pull/tag/listReleases/fetchArchive throw NOT_IMPLEMENTED — the rest of TIGER-364. The HTTP layer is a seam (setHttp), the token is resolved per request via Tiger_Git_Credential (never held), and every call is pinned to api.github.com. Errors map to stable, secret-free codes (401->CREDENTIAL_INVALID, 403 rate->RATE_LIMITED, 404->INVALID_REF, 422 'fast forward'->NON_FAST_FORWARD). Tests: 10 unit (29 across the Git suite) — the full four-step sequence + payloads asserted against canned GitHub responses (no network), plus NON_FAST_FORWARD, missing-credential (no call made), nothing-to-commit, empty-message, createBranch, branches, push no-op, and the NOT_IMPLEMENTED guards. CAPABILITIES.md regenerated. Built on the Phase-1 substrate (PR #356); rebase onto main once that merges. A live-GitHub integration test (real throwaway repo + token) is the Testing-column gate. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ASauLLscjqdsNqBNsx2Typ --- CAPABILITIES.md | 5 +- library/Tiger/Git/Exception.php | 2 + library/Tiger/Git/Transport/Api.php | 320 ++++++++++++++++++++++ tests/Unit/Git/ApiTransportCommitTest.php | 228 +++++++++++++++ 4 files changed, 553 insertions(+), 2 deletions(-) create mode 100644 library/Tiger/Git/Transport/Api.php create mode 100644 tests/Unit/Git/ApiTransportCommitTest.php diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 37171046..4b4917ae 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. -**240 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). +**241 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`) @@ -328,6 +328,7 @@ - **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_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_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` @@ -359,7 +360,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` -- **Git** (`git`, app) · `modules/git` +- **TigerGit** (`git`, app) · `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/library/Tiger/Git/Exception.php b/library/Tiger/Git/Exception.php index e0c2e580..4d8dcf08 100644 --- a/library/Tiger/Git/Exception.php +++ b/library/Tiger/Git/Exception.php @@ -30,6 +30,8 @@ class Tiger_Git_Exception extends RuntimeException const INVALID_REF = 'INVALID_REF'; const API_ERROR = 'API_ERROR'; const SHELL_ERROR = 'SHELL_ERROR'; + const NOT_IMPLEMENTED = 'NOT_IMPLEMENTED'; // a transport method not yet built (phased delivery) + const NOTHING_TO_COMMIT = 'NOTHING_TO_COMMIT'; /** @var string the stable error code */ protected $gitCode; diff --git a/library/Tiger/Git/Transport/Api.php b/library/Tiger/Git/Transport/Api.php new file mode 100644 index 00000000..65cc71dd --- /dev/null +++ b/library/Tiger/Git/Transport/Api.php @@ -0,0 +1,320 @@ + 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]); + + 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]); + } + + // --- deferred to the rest of TIGER-364 (status/diff/switch/pull/tag/releases/archive) -------- + + public function status(Tiger_Git_WorkingTree $tree): array + { + throw $this->notImplemented('status'); + } + + public function diff(Tiger_Git_WorkingTree $tree, array $opts = []): array + { + throw $this->notImplemented('diff'); + } + + public function switchBranch(Tiger_Git_WorkingTree $tree, string $name): void + { + throw $this->notImplemented('switchBranch'); + } + + public function pull(Tiger_Git_WorkingTree $tree, string $branch): array + { + throw $this->notImplemented('pull'); + } + + public function tag(Tiger_Git_WorkingTree $tree, string $tag, ?string $ref = null): array + { + throw $this->notImplemented('tag'); + } + + public function listReleases(Tiger_Git_WorkingTree $tree): array + { + throw $this->notImplemented('listReleases'); + } + + public function fetchArchive(Tiger_Git_WorkingTree $tree, string $tag, string $destFile): string + { + throw $this->notImplemented('fetchArchive'); + } + + // --- internals ------------------------------------------------------------------------------- + + private function notImplemented(string $op): Tiger_Git_Exception + { + return new Tiger_Git_Exception(Tiger_Git_Exception::NOT_IMPLEMENTED, "ApiTransport::$op lands in the rest of TIGER-364"); + } + + 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 = [ + 'Authorization' => 'Bearer ' . $this->token($tree), + 'Accept' => 'application/vnd.github+json', + 'X-GitHub-Api-Version' => '2022-11-28', + 'User-Agent' => 'Tiger-Git', + ]; + $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. Returns + * `{status:int, body:string}`. + * + * @param array $headers + */ + 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'] ?? '')]; + } + 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; } + $opts = [ + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $hdr, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_FOLLOWLOCATION => false, // never follow a redirect off the allowlisted host + ]; + 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]; + } +} diff --git a/tests/Unit/Git/ApiTransportCommitTest.php b/tests/Unit/Git/ApiTransportCommitTest.php new file mode 100644 index 00000000..8480434c --- /dev/null +++ b/tests/Unit/Git/ApiTransportCommitTest.php @@ -0,0 +1,228 @@ +dir = sys_get_temp_dir() . '/tiger-git-api-' . bin2hex(random_bytes(4)); + mkdir($this->dir . '/src', 0700, true); + file_put_contents($this->dir . '/src/a.php', 'hello world'); + } + + protected function tearDown(): void + { + @unlink($this->dir . '/src/a.php'); + @rmdir($this->dir . '/src'); + @rmdir($this->dir); + parent::tearDown(); + } + + private function tree(): Tiger_Git_WorkingTree + { + return new Tiger_Git_WorkingTree($this->dir, 'acme', 'mod', 'main', 'main', 'api', 'cred-1', true); + } + + /** A credential manager that resolves to a fixed token (or null) — no DB. */ + private function creds(?string $token): Tiger_Git_Credential + { + return new class($token) extends Tiger_Git_Credential { + public function __construct(private ?string $token) {} + public function resolve(string $ref): ?string { return $this->token; } + }; + } + + /** + * A GitHub API fake. Records every call into $calls (by ref) and returns canned happy-path responses; + * $override lets a test replace the response for a matched (method, url-substring). + * + * @param array $calls + */ + private function http(array &$calls, array $override = []): callable + { + return function (string $method, string $url, array $headers, ?string $body) use (&$calls, $override): array { + $calls[] = ['method' => $method, 'url' => $url, 'headers' => $headers, 'json' => $body !== null ? json_decode($body, true) : null]; + foreach ($override as [$m, $needle, $resp]) { + if ($method === $m && str_contains($url, $needle)) { return $resp; } + } + if ($method === 'GET' && str_contains($url, '/git/ref/heads/')) { return ['status' => 200, 'body' => json_encode(['object' => ['sha' => 'HEAD1']])]; } + if ($method === 'GET' && str_contains($url, '/git/commits/')) { return ['status' => 200, 'body' => json_encode(['tree' => ['sha' => 'TREE0']])]; } + if ($method === 'POST' && str_contains($url, '/git/blobs')) { return ['status' => 201, 'body' => json_encode(['sha' => 'BLOB1'])]; } + if ($method === 'POST' && str_contains($url, '/git/trees')) { return ['status' => 201, 'body' => json_encode(['sha' => 'TREE1'])]; } + if ($method === 'POST' && str_contains($url, '/git/commits')) { return ['status' => 201, 'body' => json_encode(['sha' => 'NEW1'])]; } + if ($method === 'POST' && str_contains($url, '/git/refs')) { return ['status' => 201, 'body' => json_encode(['ref' => 'refs/heads/new'])]; } + if ($method === 'PATCH' && str_contains($url, '/git/refs/heads/')) { return ['status' => 200, 'body' => json_encode(['object' => ['sha' => 'NEW1']])]; } + return ['status' => 404, 'body' => json_encode(['message' => 'not found'])]; + }; + } + + #[Test] + public function commit_runs_the_blob_tree_commit_ref_dance_and_returns_the_new_sha(): void + { + $calls = []; + $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $this->http($calls)); + + // Commit one modified file (on disk) and one deleted file (not on disk). + $sha = $api->commit($this->tree(), 'Add feature', ['src/a.php', 'old.php']); + + $this->assertSame('NEW1', $sha); + + // The four-step sequence, in order. + $seq = array_map(fn ($c) => $c['method'] . ' ' . parse_url($c['url'], PHP_URL_PATH), $calls); + $this->assertSame([ + 'GET /repos/acme/mod/git/ref/heads/main', + 'GET /repos/acme/mod/git/commits/HEAD1', + 'POST /repos/acme/mod/git/blobs', + 'POST /repos/acme/mod/git/trees', + 'POST /repos/acme/mod/git/commits', + 'PATCH /repos/acme/mod/git/refs/heads/main', + ], $seq); + + // The blob is the file content, base64. + $blob = $calls[2]['json']; + $this->assertSame(base64_encode('hello world'), $blob['content']); + $this->assertSame('base64', $blob['encoding']); + + // The tree builds on the base tree; the modified file is a blob entry, the missing one a deletion. + $treeBody = $calls[3]['json']; + $this->assertSame('TREE0', $treeBody['base_tree']); + $byPath = []; + foreach ($treeBody['tree'] as $e) { $byPath[$e['path']] = $e; } + $this->assertSame('BLOB1', $byPath['src/a.php']['sha']); + $this->assertSame('100644', $byPath['src/a.php']['mode']); + $this->assertNull($byPath['old.php']['sha'], 'a path absent on disk is staged as a deletion'); + + // The commit parents the old head; the ref update is a non-forced fast-forward. + $this->assertSame(['HEAD1'], $calls[4]['json']['parents']); + $this->assertSame('TREE1', $calls[4]['json']['tree']); + $this->assertSame('NEW1', $calls[5]['json']['sha']); + $this->assertFalse($calls[5]['json']['force']); + + // Every call is authenticated. + $this->assertSame('Bearer ghp_token', $calls[0]['headers']['Authorization']); + } + + #[Test] + public function a_non_fast_forward_ref_update_is_reported_not_forced(): void + { + $calls = []; + $http = $this->http($calls, [ + ['PATCH', '/git/refs/heads/', ['status' => 422, 'body' => json_encode(['message' => 'Update is not a fast forward'])]], + ]); + $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $http); + + try { + $api->commit($this->tree(), 'msg', ['src/a.php']); + $this->fail('expected a NON_FAST_FORWARD exception'); + } catch (Tiger_Git_Exception $e) { + $this->assertSame(Tiger_Git_Exception::NON_FAST_FORWARD, $e->code()); + } + } + + #[Test] + public function an_empty_path_list_is_nothing_to_commit(): void + { + $t = []; $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $this->http($t)); + $this->expectException(Tiger_Git_Exception::class); + $this->expectExceptionMessageMatches('/explicit paths/'); + $api->commit($this->tree(), 'msg', []); + } + + #[Test] + public function an_empty_message_is_refused(): void + { + $t = []; $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $this->http($t)); + $this->expectException(Tiger_Git_Exception::class); + $api->commit($this->tree(), ' ', ['src/a.php']); + } + + #[Test] + public function a_missing_credential_fails_before_any_write(): void + { + $calls = []; + $api = new Tiger_Git_Transport_Api($this->creds(null), $this->http($calls)); + try { + $api->commit($this->tree(), 'msg', ['src/a.php']); + $this->fail('expected CREDENTIAL_MISSING'); + } catch (Tiger_Git_Exception $e) { + $this->assertSame(Tiger_Git_Exception::CREDENTIAL_MISSING, $e->code()); + } + $this->assertSame([], $calls, 'no API call is made without a credential'); + } + + #[Test] + public function create_branch_posts_a_new_ref_off_the_current_head(): void + { + $calls = []; + $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $this->http($calls)); + $api->createBranch($this->tree(), 'feature/x'); + + $post = $calls[1]; + $this->assertSame('POST', $post['method']); + $this->assertStringEndsWith('/git/refs', parse_url($post['url'], PHP_URL_PATH)); + $this->assertSame('refs/heads/feature/x', $post['json']['ref']); + $this->assertSame('HEAD1', $post['json']['sha']); + } + + #[Test] + public function create_branch_refuses_the_default_branch(): void + { + $t = []; $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $this->http($t)); + $this->expectException(Tiger_Git_Exception::class); + $api->createBranch($this->tree(), 'main'); + } + + #[Test] + public function branches_lists_names(): void + { + $calls = []; + $http = $this->http($calls, [ + ['GET', '/branches', ['status' => 200, 'body' => json_encode([['name' => 'main'], ['name' => 'dev']])]], + ]); + $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $http); + $this->assertSame(['main', 'dev'], $api->branches($this->tree())); + } + + #[Test] + public function push_is_a_verified_no_op_on_the_api_transport(): void + { + $calls = []; + $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $this->http($calls)); + $res = $api->push($this->tree(), 'main'); + $this->assertTrue($res['pushed']); + $this->assertSame('HEAD1', $res['ref']); + } + + #[Test] + public function deferred_methods_report_not_implemented(): void + { + $t = []; $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $this->http($t)); + try { + $api->status($this->tree()); + $this->fail('expected NOT_IMPLEMENTED'); + } catch (Tiger_Git_Exception $e) { + $this->assertSame(Tiger_Git_Exception::NOT_IMPLEMENTED, $e->code()); + } + } +} From c408874c7bc2b11227148d7ab42ae8984f235b15 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 21 Oct 2026 13:59:18 -0400 Subject: [PATCH 04/10] =?UTF-8?q?feat(git):=20ShellTransport=20=E2=80=94?= =?UTF-8?q?=20the=20git-binary=20transport=20(TIGER-364)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The counterpart to ApiTransport, behind the same interface: git operations driven by the `git` binary over a real .git working tree, for a host that has git and can spawn a subprocess. Host-agnostic (works against any git remote, not just GitHub). Implemented: status (branch + porcelain changes + ahead/behind vs upstream), diff (numstat, size-capped), commit (stage -> -c author commit -> rev-parse; NOTHING_TO_COMMIT when empty), branches, createBranch (off HEAD, refuses the default), switchBranch (refuses a dirty tree -> DIRTY_TREE), push (origin, NO --force ever -> NON_FAST_FORWARD on rejection), pull (--ff-only), tag (annotated + pushed to origin). listReleases/fetchArchive are a provider-API concern (GitHub adapter routes them to ApiTransport) -> NOT_IMPLEMENTED. Safety, structural: git is invoked via proc_open with an ARGUMENT ARRAY (no shell string, no metacharacter interpretation); only a fixed subcommand set runs; every path is resolved under the module root first; ref/branch/tag names are validated (no leading '-', no '..'); a token is injected through an inline credential.helper that reads GIT_TOKEN from the ENV, so the secret is never in argv (ps-visible) nor written to .git/config. A command-runner seam (setRunner) allows stubbing. Tests: 12, proven END TO END against a real local repo + a bare file:// "remote", with a second clone used to force a genuine NON_FAST_FORWARD (push refused, never forced) and to verify push/tag actually reach the remote. Skipped where git is absent. Full Git suite 41 green; full unit suite 1172 green. CAPABILITIES.md regenerated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ASauLLscjqdsNqBNsx2Typ --- CAPABILITIES.md | 3 +- library/Tiger/Git/Transport/Shell.php | 320 ++++++++++++++++++++++++++ tests/Unit/Git/ShellTransportTest.php | 227 ++++++++++++++++++ 3 files changed, 549 insertions(+), 1 deletion(-) create mode 100644 library/Tiger/Git/Transport/Shell.php create mode 100644 tests/Unit/Git/ShellTransportTest.php diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 4b4917ae..16b1cbc1 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. -**241 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). +**242 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`) @@ -330,6 +330,7 @@ - **Tiger_Git_Exception** `@api` — a structured, agent-readable, SECRET-FREE git failure (TIGER-334). · `library/Tiger/Git/Exception.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` 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/tests/Unit/Git/ShellTransportTest.php b/tests/Unit/Git/ShellTransportTest.php new file mode 100644 index 00000000..e19f504f --- /dev/null +++ b/tests/Unit/Git/ShellTransportTest.php @@ -0,0 +1,227 @@ +markTestSkipped('git binary not available'); + } + $this->base = sys_get_temp_dir() . '/tiger-git-sh-' . bin2hex(random_bytes(4)); + $this->work = $this->base . '/work'; + $this->remote = $this->base . '/remote.git'; + mkdir($this->base, 0700, true); + + $this->raw($this->base, ['init', '--bare', '-b', 'main', 'remote.git']); + $this->raw($this->base, ['init', '-b', 'main', 'work']); + $this->raw($this->work, ['config', 'user.email', 'dev@test']); + $this->raw($this->work, ['config', 'user.name', 'Dev']); + file_put_contents($this->work . '/README.md', "init\n"); + $this->raw($this->work, ['add', '-A']); + $this->raw($this->work, ['commit', '-m', 'initial']); + $this->raw($this->work, ['remote', 'add', 'origin', $this->remote]); + $this->raw($this->work, ['push', '-u', 'origin', 'main']); + } + + protected function tearDown(): void + { + if (isset($this->base) && is_dir($this->base)) { $this->rrmdir($this->base); } + parent::tearDown(); + } + + private function tree(): Tiger_Git_WorkingTree + { + return new Tiger_Git_WorkingTree($this->work, 'acme', 'mod', 'main', 'main', 'shell', '', false); + } + + private function transport(): Tiger_Git_Transport_Shell + { + // No token — a file:// remote needs none. + $creds = new class extends Tiger_Git_Credential { + public function resolve(string $ref): ?string { return null; } + }; + return new Tiger_Git_Transport_Shell($creds); + } + + #[Test] + public function name_is_shell(): void + { + $this->assertSame('shell', $this->transport()->name()); + } + + #[Test] + public function status_is_clean_then_sees_a_change(): void + { + $t = $this->transport(); + $clean = $t->status($this->tree()); + $this->assertSame('main', $clean['branch']); + $this->assertTrue($clean['clean']); + + file_put_contents($this->work . '/new.php', "status($this->tree()); + $this->assertFalse($dirty['clean']); + $this->assertContains('new.php', array_column($dirty['changed'], 'path')); + } + + #[Test] + public function commit_stages_and_returns_a_sha(): void + { + file_put_contents($this->work . '/feature.php', "transport()->commit($this->tree(), 'Add feature', ['feature.php']); + $this->assertMatchesRegularExpression('/^[0-9a-f]{40}$/', $sha); + $this->assertTrue($this->transport()->status($this->tree())['clean'], 'tree is clean after commit'); + } + + #[Test] + public function commit_with_nothing_staged_is_nothing_to_commit(): void + { + $this->expectException(Tiger_Git_Exception::class); + $this->expectExceptionMessageMatches('/nothing to commit/i'); + $this->transport()->commit($this->tree(), 'empty'); // no changes on disk + } + + #[Test] + public function create_and_list_branches(): void + { + $t = $this->transport(); + $t->createBranch($this->tree(), 'feature/x'); + $this->assertContains('feature/x', $t->branches($this->tree())); + } + + #[Test] + public function create_branch_refuses_the_default(): void + { + $this->expectException(Tiger_Git_Exception::class); + $this->transport()->createBranch($this->tree(), 'main'); + } + + #[Test] + public function switch_branch_refuses_a_dirty_tree(): void + { + $t = $this->transport(); + $t->createBranch($this->tree(), 'feature/x'); + file_put_contents($this->work . '/dirty.php', "switchBranch($this->tree(), 'feature/x'); + $this->fail('expected DIRTY_TREE'); + } catch (Tiger_Git_Exception $e) { + $this->assertSame(Tiger_Git_Exception::DIRTY_TREE, $e->code()); + } + } + + #[Test] + public function push_advances_the_remote(): void + { + file_put_contents($this->work . '/p.php', "transport()->commit($this->tree(), 'to push', ['p.php']); + $res = $this->transport()->push($this->tree(), 'main'); + $this->assertTrue($res['pushed']); + + $localHead = trim($this->raw($this->work, ['rev-parse', 'HEAD'])['out']); + $remoteHead = trim($this->raw($this->remote, ['rev-parse', 'main'])['out']); + $this->assertSame($localHead, $remoteHead, 'the bare remote advanced to the pushed commit'); + } + + #[Test] + public function push_refuses_a_non_fast_forward(): void + { + // A second clone advances the remote behind our back. + $this->raw($this->base, ['clone', $this->remote, 'other']); + $other = $this->base . '/other'; + $this->raw($other, ['config', 'user.email', 'o@test']); + $this->raw($other, ['config', 'user.name', 'Other']); + file_put_contents($other . '/remote-change.php', "raw($other, ['add', '-A']); + $this->raw($other, ['commit', '-m', 'remote moves']); + $this->raw($other, ['push', 'origin', 'main']); + + // Now our local commits on a now-stale main and pushes → must be refused, never forced. + file_put_contents($this->work . '/local-change.php', "transport()->commit($this->tree(), 'local moves', ['local-change.php']); + try { + $this->transport()->push($this->tree(), 'main'); + $this->fail('expected NON_FAST_FORWARD'); + } catch (Tiger_Git_Exception $e) { + $this->assertSame(Tiger_Git_Exception::NON_FAST_FORWARD, $e->code()); + } + } + + #[Test] + public function tag_creates_annotated_and_pushes_it(): void + { + $out = $this->transport()->tag($this->tree(), 'v1.0.0'); + $this->assertSame('v1.0.0', $out['tag']); + $this->assertMatchesRegularExpression('/^[0-9a-f]{40}$/', $out['sha']); + $remoteTags = $this->raw($this->remote, ['tag'])['out']; + $this->assertStringContainsString('v1.0.0', $remoteTags, 'the tag reached the remote'); + } + + #[Test] + public function diff_reports_numstat(): void + { + file_put_contents($this->work . '/README.md', "init\nmore\nlines\n"); + $diff = $this->transport()->diff($this->tree()); + $paths = array_column($diff['files'], 'path'); + $this->assertContains('README.md', $paths); + } + + #[Test] + public function release_ops_are_a_provider_concern(): void + { + try { + $this->transport()->listReleases($this->tree()); + $this->fail('expected NOT_IMPLEMENTED'); + } catch (Tiger_Git_Exception $e) { + $this->assertSame(Tiger_Git_Exception::NOT_IMPLEMENTED, $e->code()); + } + } + + // --- helpers --------------------------------------------------------------------------------- + + /** Run git directly for setup/verification (asserts success). */ + private function raw(string $cwd, array $args): array + { + $desc = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; + $proc = proc_open(array_merge(['git'], $args), $desc, $pipes, $cwd); + $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); + $this->assertSame(0, $code, "setup `git " . implode(' ', $args) . "` failed: $err"); + return ['out' => $out, 'err' => $err]; + } + + private function rrmdir(string $dir): void + { + $it = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($it as $f) { $f->isDir() ? @rmdir($f->getPathname()) : @unlink($f->getPathname()); } + @rmdir($dir); + } +} From 8a172ba7d1023d961b28c08d2fddc35970aef647 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 21 Oct 2026 15:18:49 -0400 Subject: [PATCH 05/10] =?UTF-8?q?feat(git):=20GitHubAdapter=20=E2=80=94=20?= =?UTF-8?q?pick=20transport,=20route=20release=20ops=20to=20the=20API=20(T?= =?UTF-8?q?IGER-364)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tiger_Git_Provider_GitHub composes the two transports into one object a service uses without caring: it picks the WORKING transport for a connection (the git binary where the host allows it, the API otherwise) for the git ops, and ALWAYS routes the provider-API ops (listReleases/fetchArchive) to the API — because a GitHub release is not a git concept, so even a shell connection reaches it over the API. New Tiger_Git_Provider_Interface is the N-provider seam (GitLab/Bitbucket land later without touching the transports). Implements the two provider-API verbs on ApiTransport (their reason to exist): - listReleases() — newest first, drafts excluded. - fetchArchive() — GitHub's tarball 302 captured; the redirect host is confirmed against the allowlist before the CDN fetch, and our token is never forwarded to the signed codeload URL. send() now captures response headers for the capture. Remaining ApiTransport verbs (status/diff/switch/pull/tag over the API) stay NOT_IMPLEMENTED — the rest of TIGER-364. 10 new unit tests (routing proven with both seams; releases/archive incl. the 302 capture, host-allowlist refusal, bad-tag refusal). 68 Git, 1181 unit green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ASauLLscjqdsNqBNsx2Typ --- CAPABILITIES.md | 4 +- library/Tiger/Git/Provider/GitHub.php | 148 +++++++++++++++++++ library/Tiger/Git/Provider/Interface.php | 25 ++++ library/Tiger/Git/Transport/Api.php | 145 ++++++++++++++++--- tests/Unit/Git/ApiTransportReleaseTest.php | 156 +++++++++++++++++++++ tests/Unit/Git/GitHubProviderTest.php | 131 +++++++++++++++++ 6 files changed, 588 insertions(+), 21 deletions(-) create mode 100644 library/Tiger/Git/Provider/GitHub.php create mode 100644 library/Tiger/Git/Provider/Interface.php create mode 100644 tests/Unit/Git/ApiTransportReleaseTest.php create mode 100644 tests/Unit/Git/GitHubProviderTest.php diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 16b1cbc1..28dadcde 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. -**242 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). +**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`) @@ -328,6 +328,8 @@ - **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` 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 @@ +api($tree, 'POST', $repo . '/git/refs', ['ref' => 'refs/heads/' . $name, 'sha' => $sha]); } - // --- deferred to the rest of TIGER-364 (status/diff/switch/pull/tag/releases/archive) -------- + // --- 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; + } + + // --- deferred to the rest of TIGER-364 (status/diff/switch/pull/tag over the API) ------------- public function status(Tiger_Git_WorkingTree $tree): array { @@ -185,21 +262,42 @@ public function tag(Tiger_Git_WorkingTree $tree, string $tag, ?string $ref = nul throw $this->notImplemented('tag'); } - public function listReleases(Tiger_Git_WorkingTree $tree): array + // --- internals ------------------------------------------------------------------------------- + + private function notImplemented(string $op): Tiger_Git_Exception { - throw $this->notImplemented('listReleases'); + return new Tiger_Git_Exception(Tiger_Git_Exception::NOT_IMPLEMENTED, "ApiTransport::$op lands in the rest of TIGER-364"); } - public function fetchArchive(Tiger_Git_WorkingTree $tree, string $tag, string $destFile): string + /** A tag name GitHub will accept in a URL path and that carries no traversal/option trickery. */ + private function assertTag(string $tag): void { - throw $this->notImplemented('fetchArchive'); + 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'); + } } - // --- internals ------------------------------------------------------------------------------- + /** 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 ''; + } - private function notImplemented(string $op): Tiger_Git_Exception + /** 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 new Tiger_Git_Exception(Tiger_Git_Exception::NOT_IMPLEMENTED, "ApiTransport::$op lands in the rest of TIGER-364"); + 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 @@ -233,12 +331,7 @@ private function api(Tiger_Git_WorkingTree $tree, string $method, string $path, if (!Tiger_Git::isAllowedHost(self::HOST)) { throw new Tiger_Git_Exception(Tiger_Git_Exception::HOST_NOT_ALLOWED, 'api.github.com is not allowed'); } - $headers = [ - 'Authorization' => 'Bearer ' . $this->token($tree), - 'Accept' => 'application/vnd.github+json', - 'X-GitHub-Api-Version' => '2022-11-28', - 'User-Agent' => 'Tiger-Git', - ]; + $headers = $this->authHeaders($tree); $body = $json !== null ? json_encode($json) : null; if ($body !== null) { $headers['Content-Type'] = 'application/json'; } @@ -281,16 +374,22 @@ private function mapError(int $status, array $decoded, string $raw): Tiger_Git_E } /** - * The raw HTTP call — the seam. Tests inject canned responses; production uses cURL. Returns - * `{status:int, body:string}`. + * 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'] ?? '')]; + 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'); @@ -298,14 +397,20 @@ private function send(string $method, string $url, array $headers, ?string $body $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 => 30, + 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); @@ -315,6 +420,6 @@ private function send(string $method, string $url, array $headers, ?string $body 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]; + return ['status' => $code, 'body' => (string) $out, 'headers' => $respHeaders]; } } diff --git a/tests/Unit/Git/ApiTransportReleaseTest.php b/tests/Unit/Git/ApiTransportReleaseTest.php new file mode 100644 index 00000000..98aac15f --- /dev/null +++ b/tests/Unit/Git/ApiTransportReleaseTest.php @@ -0,0 +1,156 @@ +dir = sys_get_temp_dir() . '/tiger-git-rel-' . bin2hex(random_bytes(4)); + mkdir($this->dir, 0700, true); + } + + protected function tearDown(): void + { + @unlink($this->dir . '/archive.tar.gz'); + @rmdir($this->dir); + parent::tearDown(); + } + + private function tree(): Tiger_Git_WorkingTree + { + return new Tiger_Git_WorkingTree($this->dir, 'acme', 'mod', 'main', 'main', 'api', 'cred-1', true); + } + + private function creds(?string $token): Tiger_Git_Credential + { + return new class($token) extends Tiger_Git_Credential { + public function __construct(private ?string $token) {} + public function resolve(string $ref): ?string { return $this->token; } + }; + } + + /** @param array $calls */ + private function http(array &$calls, callable $responder): callable + { + return function (string $method, string $url, array $headers, ?string $body) use (&$calls, $responder): array { + $calls[] = ['method' => $method, 'url' => $url, 'headers' => $headers, 'body' => $body]; + return $responder($method, $url); + }; + } + + #[Test] + public function list_releases_maps_newest_first_and_skips_drafts(): void + { + $calls = []; + $http = $this->http($calls, function (string $m, string $url): array { + if (str_contains($url, '/releases')) { + return ['status' => 200, 'body' => json_encode([ + ['tag_name' => 'v1.1.0', 'name' => 'Spring release', 'target_commitish' => 'abc123', 'created_at' => '2026-03-01T00:00:00Z', 'prerelease' => false, 'draft' => false], + ['tag_name' => 'v1.1.0-rc1', 'name' => '', 'target_commitish' => 'main', 'created_at' => '2026-02-01T00:00:00Z', 'prerelease' => true, 'draft' => false], + ['tag_name' => 'wip', 'name' => 'Draft', 'target_commitish' => 'main', 'created_at' => '2026-02-15T00:00:00Z', 'prerelease' => false, 'draft' => true], + ])]; + } + return ['status' => 404, 'body' => '[]']; + }); + $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $http); + + $releases = $api->listReleases($this->tree()); + + $this->assertCount(2, $releases, 'the draft is excluded'); + $this->assertSame('v1.1.0', $releases[0]['tag']); + $this->assertSame('Spring release', $releases[0]['name']); + $this->assertSame('abc123', $releases[0]['sha']); + $this->assertFalse($releases[0]['prerelease']); + + // Empty name falls back to the tag; the prerelease flag is carried. + $this->assertSame('v1.1.0-rc1', $releases[1]['name']); + $this->assertTrue($releases[1]['prerelease']); + + $this->assertStringContainsString('/repos/acme/mod/releases', $calls[0]['url']); + $this->assertSame('Bearer ghp_token', $calls[0]['headers']['Authorization']); + } + + #[Test] + public function fetch_archive_captures_the_302_and_streams_from_codeload(): void + { + $calls = []; + $http = $this->http($calls, function (string $m, string $url): array { + if (str_contains($url, 'api.github.com') && str_contains($url, '/tarball/')) { + return ['status' => 302, 'headers' => ['Location' => 'https://codeload.github.com/acme/mod/tar.gz/v1.0.0'], 'body' => '']; + } + if (str_contains($url, 'codeload.github.com')) { + return ['status' => 200, 'body' => 'TARBALL-BYTES']; + } + return ['status' => 404, 'body' => 'nope']; + }); + $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $http); + + $dest = $this->dir . '/archive.tar.gz'; + $path = $api->fetchArchive($this->tree(), 'v1.0.0', $dest); + + $this->assertSame($dest, $path); + $this->assertSame('TARBALL-BYTES', file_get_contents($dest)); + + // Two hops: the api.github.com tarball request (authenticated), then the CDN (NOT authenticated). + $this->assertStringContainsString('api.github.com', $calls[0]['url']); + $this->assertSame('Bearer ghp_token', $calls[0]['headers']['Authorization']); + $this->assertStringContainsString('codeload.github.com', $calls[1]['url']); + $this->assertArrayNotHasKey('Authorization', $calls[1]['headers'], 'the signed CDN URL is not sent our token'); + } + + #[Test] + public function fetch_archive_refuses_a_redirect_off_the_allowlist(): void + { + $calls = []; + $http = $this->http($calls, fn (string $m, string $url): array => + ['status' => 302, 'headers' => ['Location' => 'https://evil.example.com/acme/mod.tar.gz'], 'body' => ''] + ); + $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $http); + + try { + $api->fetchArchive($this->tree(), 'v1.0.0', $this->dir . '/archive.tar.gz'); + $this->fail('expected HOST_NOT_ALLOWED'); + } catch (Tiger_Git_Exception $e) { + $this->assertSame(Tiger_Git_Exception::HOST_NOT_ALLOWED, $e->code()); + } + $this->assertCount(1, $calls, 'the off-allowlist redirect is never followed'); + $this->assertFileDoesNotExist($this->dir . '/archive.tar.gz'); + } + + #[Test] + public function fetch_archive_refuses_a_bad_tag_before_any_call(): void + { + $calls = []; + $http = $this->http($calls, fn () => ['status' => 200, 'body' => '']); + $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $http); + + try { + $api->fetchArchive($this->tree(), '../../etc/passwd', $this->dir . '/x.tar.gz'); + $this->fail('expected INVALID_REF'); + } catch (Tiger_Git_Exception $e) { + $this->assertSame(Tiger_Git_Exception::INVALID_REF, $e->code()); + } + $this->assertSame([], $calls, 'nothing is fetched for a rejected tag'); + } +} diff --git a/tests/Unit/Git/GitHubProviderTest.php b/tests/Unit/Git/GitHubProviderTest.php new file mode 100644 index 00000000..f0bf1901 --- /dev/null +++ b/tests/Unit/Git/GitHubProviderTest.php @@ -0,0 +1,131 @@ + git commands the runner saw */ + private array $gitCalls = []; + /** @var array HTTP requests the API seam saw */ + private array $httpCalls = []; + + protected function setUp(): void + { + parent::setUp(); + $this->dir = sys_get_temp_dir() . '/tiger-git-prov-' . bin2hex(random_bytes(4)); + mkdir($this->dir, 0700, true); + Tiger_Git_Environment::setProbe(fn () => true); // keep detect() off the network + } + + protected function tearDown(): void + { + Tiger_Git_Environment::setProbe(null); + @rmdir($this->dir); + parent::tearDown(); + } + + private function tree(string $transport): Tiger_Git_WorkingTree + { + return new Tiger_Git_WorkingTree($this->dir, 'acme', 'mod', 'main', 'main', $transport, 'cred-1', true); + } + + private function provider(): Tiger_Git_Provider_GitHub + { + $creds = new class extends Tiger_Git_Credential { + public function resolve(string $ref): ?string { return 'ghp_token'; } + }; + + // The shell runner answers just enough git for status(); it records every call. + $runner = function (array $args, string $dir, array $env): array { + $this->gitCalls[] = $args; + if ($args === ['rev-parse', '--abbrev-ref', 'HEAD']) { return ['code' => 0, 'out' => "main\n", 'err' => '']; } + if ($args === ['status', '--porcelain']) { return ['code' => 0, 'out' => '', 'err' => '']; } + if (in_array('@{upstream}', $args, true)) { return ['code' => 1, 'out' => '', 'err' => 'no upstream']; } + return ['code' => 0, 'out' => '', 'err' => '']; + }; + + // The API seam records every call and answers the few endpoints these tests reach. + $http = function (string $method, string $url, array $headers, ?string $body): array { + $this->httpCalls[] = ['method' => $method, 'url' => $url]; + if (str_contains($url, '/branches')) { + return ['status' => 200, 'body' => json_encode([['name' => 'main'], ['name' => 'dev']])]; + } + if (str_contains($url, '/releases')) { + return ['status' => 200, 'body' => json_encode([['tag_name' => 'v1.0.0', 'target_commitish' => 'abc', 'created_at' => 'now', 'prerelease' => false]])]; + } + return ['status' => 404, 'body' => '[]']; + }; + + return new Tiger_Git_Provider_GitHub($creds, $http, $runner); + } + + #[Test] + public function provider_key_is_github(): void + { + $this->assertSame('github', $this->provider()->provider()); + } + + #[Test] + public function name_reports_the_detected_host_transport(): void + { + $this->assertSame(Tiger_Git_Environment::detect()['transport'], $this->provider()->name()); + } + + #[Test] + public function a_git_op_on_a_shell_connection_uses_the_git_binary(): void + { + if (!Tiger_Git_Environment::execUsable()) { + $this->markTestSkipped('proc_open unavailable — shell routing cannot be exercised'); + } + $status = $this->provider()->status($this->tree(Tiger_Git::TRANSPORT_SHELL)); + + $this->assertSame('main', $status['branch']); + $this->assertTrue($status['clean']); + $this->assertNotEmpty($this->gitCalls, 'the git binary was driven'); + $this->assertSame([], $this->httpCalls, 'no API call for a shell git op'); + } + + #[Test] + public function a_git_op_on_an_api_connection_uses_the_api(): void + { + $branches = $this->provider()->branches($this->tree(Tiger_Git::TRANSPORT_API)); + + $this->assertSame(['main', 'dev'], $branches); + $this->assertNotEmpty($this->httpCalls, 'the API was called'); + $this->assertSame([], $this->gitCalls, 'the git binary was not touched for an API connection'); + } + + #[Test] + public function releases_go_to_the_api_even_on_a_shell_connection(): void + { + // The whole reason the provider exists: a GitHub release is not a git concept, so a shell + // connection still reaches it over the API — never the git binary. + $releases = $this->provider()->listReleases($this->tree(Tiger_Git::TRANSPORT_SHELL)); + + $this->assertSame('v1.0.0', $releases[0]['tag']); + $this->assertNotEmpty($this->httpCalls, 'releases were fetched over the API'); + $this->assertStringContainsString('/releases', $this->httpCalls[0]['url']); + $this->assertSame([], $this->gitCalls, 'the git binary is never used for releases'); + } +} From 88ab4ad1e6a9e1ecd483b9da4532bb5cb1c279a9 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 21 Oct 2026 15:50:44 -0400 Subject: [PATCH 06/10] =?UTF-8?q?feat(git):=20Phase-2=20/api=20services=20?= =?UTF-8?q?=E2=80=94=20Repo=20+=20Release=20(TIGER-367)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /api surface over the transports + GitHubAdapter. Deny-by-default acl.ini, one rule per privilege (TIGER-335); Git_Service_Base shares the connection / working-tree / adapter / audit-log / error-mapping plumbing (adapter behind a static test seam, no network). Git_Service_Repo (admin): connect / status / recheck / list / log / disconnect. connect stores the token write-only (Tiger_Git_Credential), detects the transport, and VERIFIES against the real repo via listReleases — always a remote authenticated GET (the adapter routes releases to the API, so it proves the token+repo on BOTH transports, unlike a local `branches`) — BEFORE the connection is recorded; a bad token revokes the just-stored credential and leaves nothing. A connection carries no secret. Reconnect-after-disconnect is unblocked by the active-only unique. status degrades gracefully (status_available:false) on a transport that can't yet. Git_Service_Release (admin reaches it; tag/submit are superadmin-only via the Tiger_Git::RESOURCE_RELEASE capability — named for the capability, not a role tier): tag / submit / decide (QA approve|reject) / list. A released version is immutable (tag refuses reuse). Folds in the remaining TIGER-364 verb the Release service needs: ApiTransport::tag() over the Git Data API (annotated tag object → ref; returns the tagged commit sha, so both transports agree). status/diff/switch/pull over the API remain NOT_IMPLEMENTED. Tests: 14 integration (connect/verify/no-secret/reconnect/disconnect/ACL-refusal; owner-only capability gate — admin refused, superadmin allowed — submit→decide QA, semver + immutable-tag guards) + 1 unit (tag-over-API). 1182 unit, 14 integration green; CAPABILITIES 244 classes. Admin UI (TigerPanel) + Git_Service_Update (receive-updates) are the next slices. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ASauLLscjqdsNqBNsx2Typ --- CAPABILITIES.md | 2 +- library/Tiger/Git/Transport/Api.php | 38 ++- library/Tiger/Model/ModuleRelease.php | 11 + modules/git/configs/acl.ini | 108 +++++++-- modules/git/forms/Connect.php | 53 +++++ modules/git/forms/Submit.php | 43 ++++ modules/git/languages/en/git.php | 39 +++ modules/git/services/Base.php | 167 +++++++++++++ modules/git/services/Release.php | 160 +++++++++++++ modules/git/services/Repo.php | 236 +++++++++++++++++++ tests/Integration/Git/ReleaseServiceTest.php | 208 ++++++++++++++++ tests/Integration/Git/RepoServiceTest.php | 212 +++++++++++++++++ tests/Unit/Git/ApiTransportReleaseTest.php | 27 +++ 13 files changed, 1271 insertions(+), 33 deletions(-) create mode 100644 modules/git/forms/Connect.php create mode 100644 modules/git/forms/Submit.php create mode 100644 modules/git/languages/en/git.php create mode 100644 modules/git/services/Base.php create mode 100644 modules/git/services/Release.php create mode 100644 modules/git/services/Repo.php create mode 100644 tests/Integration/Git/ReleaseServiceTest.php create mode 100644 tests/Integration/Git/RepoServiceTest.php diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 28dadcde..aa88f238 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -363,7 +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) · `modules/git` +- **TigerGit** (`git`, app) · services: Base, Release, Repo · `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/library/Tiger/Git/Transport/Api.php b/library/Tiger/Git/Transport/Api.php index 55c10224..daee634b 100644 --- a/library/Tiger/Git/Transport/Api.php +++ b/library/Tiger/Git/Transport/Api.php @@ -235,7 +235,38 @@ public function fetchArchive(Tiger_Git_WorkingTree $tree, string $tag, string $d return $destFile; } - // --- deferred to the rest of TIGER-364 (status/diff/switch/pull/tag over the API) ------------- + /** + * 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]; + } + + // --- deferred to the rest of TIGER-364 (status/diff/switch/pull over the API) ----------------- public function status(Tiger_Git_WorkingTree $tree): array { @@ -257,11 +288,6 @@ public function pull(Tiger_Git_WorkingTree $tree, string $branch): array throw $this->notImplemented('pull'); } - public function tag(Tiger_Git_WorkingTree $tree, string $tag, ?string $ref = null): array - { - throw $this->notImplemented('tag'); - } - // --- internals ------------------------------------------------------------------------------- private function notImplemented(string $op): Tiger_Git_Exception diff --git a/library/Tiger/Model/ModuleRelease.php b/library/Tiger/Model/ModuleRelease.php index 7795df92..21ec34e0 100644 --- a/library/Tiger/Model/ModuleRelease.php +++ b/library/Tiger/Model/ModuleRelease.php @@ -34,6 +34,17 @@ public function approvedForModule($orgId, $moduleKey) ); } + /** 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) { diff --git a/modules/git/configs/acl.ini b/modules/git/configs/acl.ini index dec9c8fc..8ed71826 100644 --- a/modules/git/configs/acl.ini +++ b/modules/git/configs/acl.ini @@ -1,27 +1,83 @@ -; SPDX-License-Identifier: BSD-3-Clause -; Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers. +; ============================================================================== +; 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+. ; -; Tiger_Git module ACL (TIGER-334). Phase 1 ships substrate only — no /api services or controllers -; yet, so there are no rules to declare. The authorization SHAPE is fixed here as the contract Phase 2 -; builds to (deny-by-default, per-privilege, no blanket grant, TIGER-335): -; -; - Each service method is its own privilege (never a blanket resource-level grant). The repo/release -; services are admin+ (they drive a connected GitHub repo). -; - The 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 with _isAllowed('tag'|'submit', Tiger_Git::RESOURCE_RELEASE). The service -; is reachable by an editor/admin; only the module owner holds the release privileges. -; -; Example (Phase 2, when Git_Service_Repo + Git_Service_Release exist): -; -; acl.resources.git_repo_svc.resource = "Git_Service_Repo" -; 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.resources.git_release_cap.resource = "Tiger_Git_Release" -; acl.rules.git_release_tag.role = "owner" -; acl.rules.git_release_tag.resource = "Tiger_Git_Release" -; acl.rules.git_release_tag.privilege = "tag" -; acl.rules.git_release_tag.permission = "allow" +; 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_repo_svc.resource = "Git_Service_Repo" +acl.resources.git_release_svc.resource = "Git_Service_Release" +; 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" + +; --- 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" + +; --- 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/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..7780fd15 --- /dev/null +++ b/modules/git/languages/en/git.php @@ -0,0 +1,39 @@ + '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.', + + // 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.', + + // 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/services/Base.php b/modules/git/services/Base.php new file mode 100644 index 00000000..4237d65f --- /dev/null +++ b/modules/git/services/Base.php @@ -0,0 +1,167 @@ +_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, + ]; + } + + /** 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..b3e797bf --- /dev/null +++ b/modules/git/services/Release.php @@ -0,0 +1,160 @@ +_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? + */ + 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..98f9f73a --- /dev/null +++ b/modules/git/services/Repo.php @@ -0,0 +1,236 @@ +_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/tests/Integration/Git/ReleaseServiceTest.php b/tests/Integration/Git/ReleaseServiceTest.php new file mode 100644 index 00000000..f4ea9001 --- /dev/null +++ b/tests/Integration/Git/ReleaseServiceTest.php @@ -0,0 +1,208 @@ +applyGitSchema(); + $this->seedCrypto(); + + $this->seedIdentityRows('u-super', self::ORG, 'superadmin'); + $this->seedIdentityRows('u-admin', self::ORG, 'admin'); + \Zend_Registry::set('tiger.auth.stateless', true); + + Git_Service_Base::setAdapterFactory($this->seam($this->tagHttp())); + $this->seedConnection(); + } + + protected function tearDown(): void + { + Git_Service_Base::setAdapterFactory(null); + parent::tearDown(); + } + + // --- helpers --------------------------------------------------------------------------------- + + private function applyGitSchema(): void + { + if (self::$gitSchema) { return; } + $this->db->rollBack(); + if (!$this->tableExists('git_repo_connection')) { + $m = require dirname(__DIR__, 3) . '/modules/git/migrations/20261021000001_create_git_tables.php'; + foreach ($m['up'] as $sql) { $this->db->query($sql); } + } + $this->db->beginTransaction(); + self::$gitSchema = true; + } + + private function seedCrypto(): void + { + $cfg = \Zend_Registry::isRegistered('Zend_Config') ? \Zend_Registry::get('Zend_Config') : new \Zend_Config([], true); + if ($cfg->readOnly()) { $cfg = new \Zend_Config($cfg->toArray(), true); } + $cfg->merge(new \Zend_Config(['tiger' => ['crypto' => ['key' => base64_encode(str_repeat("\x22", 32))]]], true)); + \Zend_Registry::set('Zend_Config', $cfg); + } + + private function seedConnection(): void + { + $cred = (new Tiger_Git_Credential())->store(self::ORG, self::MODULE, Tiger_Model_GitCredential::TYPE_TOKEN, 'ghp_x', []); + (new Tiger_Model_GitRepoConnection())->insert([ + 'org_id' => self::ORG, + 'module_key' => self::MODULE, + 'provider' => Tiger_Git_Provider_GitHub::PROVIDER, + 'repo_owner' => 'acme', + 'repo_name' => 'mod', + 'default_branch' => 'main', + 'transport' => Tiger_Git::TRANSPORT_API, // so tag drives the API through the seam + 'write_enabled' => 1, + 'credential_ref' => $cred['ref'], + 'status' => Tiger_Git::STATUS_CONNECTED, + ]); + } + + private function seam(callable $http): callable + { + return fn () => new Tiger_Git_Provider_GitHub(new Tiger_Git_Credential(), $http); + } + + private function tagHttp(): callable + { + return function (string $method, string $url, array $headers, ?string $body): array { + if ($method === 'GET' && str_contains($url, '/git/ref/heads/')) { return ['status' => 200, 'body' => json_encode(['object' => ['sha' => 'HEADSHA']])]; } + if ($method === 'POST' && str_contains($url, '/git/tags')) { return ['status' => 201, 'body' => json_encode(['sha' => 'TAGOBJ'])]; } + if ($method === 'POST' && str_contains($url, '/git/refs')) { return ['status' => 201, 'body' => json_encode(['ref' => 'refs/tags/x'])]; } + if (str_contains($url, '/releases')) { return ['status' => 200, 'body' => '[]']; } + return ['status' => 404, 'body' => '{}']; + }; + } + + private function call(string $method, array $params = []): array + { + $svc = new Git_Service_Release(); + $svc->$method($params); + $res = $svc->getResponse(); + $msgs = []; + foreach ((array) $res->messages as $m) { $msgs[] = (string) $m->message; } + return ['result' => (int) $res->result, 'data' => json_decode(json_encode($res->data), true) ?: [], 'messages' => $msgs]; + } + + // --- tests ----------------------------------------------------------------------------------- + + #[Test] + public function superadmin_can_tag(): void + { + $this->login('u-super', self::ORG, 'superadmin'); + $res = $this->call('tag', ['module_key' => self::MODULE, 'version' => '1.0.0']); + + $this->assertSame(1, $res['result'], 'the owner tagged'); + $this->assertSame('1.0.0', $res['data']['tag']); + $this->assertSame('HEADSHA', $res['data']['sha']); + } + + #[Test] + public function an_admin_cannot_tag(): void + { + $this->login('u-admin', self::ORG, 'admin'); + $res = $this->call('tag', ['module_key' => self::MODULE, 'version' => '1.0.0']); + + $this->assertSame(0, $res['result'], 'an ordinary admin is refused the release capability'); + $this->assertContains('git.error.not_allowed', $res['messages']); + } + + #[Test] + public function tag_refuses_a_non_semver_version(): void + { + $this->login('u-super', self::ORG, 'superadmin'); + $res = $this->call('tag', ['module_key' => self::MODULE, 'version' => 'not-a-version']); + $this->assertSame(0, $res['result']); + $this->assertContains('git.error.invalid_version', $res['messages']); + } + + #[Test] + public function tag_refuses_a_version_already_released(): void + { + $this->login('u-super', self::ORG, 'superadmin'); + (new Tiger_Model_ModuleRelease())->insert([ + 'org_id' => self::ORG, 'module_key' => self::MODULE, 'version' => '2.0.0', 'git_tag' => '2.0.0', + 'commit_sha' => 'deadbeef', 'status' => Tiger_Model_ModuleRelease::STATUS_SUBMITTED, 'submitted_at' => gmdate('Y-m-d H:i:s'), + ]); + $res = $this->call('tag', ['module_key' => self::MODULE, 'version' => '2.0.0']); + $this->assertSame(0, $res['result']); + $this->assertContains('git.error.tag_exists', $res['messages']); + } + + #[Test] + public function submit_records_a_release_for_qa(): void + { + $this->login('u-super', self::ORG, 'superadmin'); + $res = $this->call('submit', ['module_key' => self::MODULE, 'version' => '1.2.0', 'commit_sha' => 'abc1234']); + + $this->assertSame(1, $res['result']); + $this->assertSame(Tiger_Model_ModuleRelease::STATUS_SUBMITTED, $res['data']['status']); + $row = (new Tiger_Model_ModuleRelease())->byTag(self::ORG, self::MODULE, '1.2.0'); + $this->assertNotNull($row); + $this->assertSame('abc1234', (string) $row->commit_sha); + } + + #[Test] + public function decide_approves_a_submitted_release(): void + { + $this->login('u-super', self::ORG, 'superadmin'); + $this->call('submit', ['module_key' => self::MODULE, 'version' => '1.3.0', 'commit_sha' => 'feedface']); + $id = (string) (new Tiger_Model_ModuleRelease())->byTag(self::ORG, self::MODULE, '1.3.0')->module_release_id; + + $this->login('u-admin', self::ORG, 'admin'); // QA review is admin-level + $res = $this->call('decide', ['module_release_id' => $id, 'decision' => 'approve']); + + $this->assertSame(1, $res['result']); + $this->assertSame(Tiger_Model_ModuleRelease::STATUS_APPROVED, $res['data']['status']); + $approved = (new Tiger_Model_ModuleRelease())->approvedForModule(self::ORG, self::MODULE); + $this->assertCount(1, $approved); + } + + #[Test] + public function decide_rejects_and_list_reflects_it(): void + { + $this->login('u-super', self::ORG, 'superadmin'); + $this->call('submit', ['module_key' => self::MODULE, 'version' => '1.4.0', 'commit_sha' => 'cafebabe']); + $id = (string) (new Tiger_Model_ModuleRelease())->byTag(self::ORG, self::MODULE, '1.4.0')->module_release_id; + + $this->login('u-admin', self::ORG, 'admin'); + $this->assertSame(1, $this->call('decide', ['module_release_id' => $id, 'decision' => 'reject'])['result']); + + $list = $this->call('list', ['module_key' => self::MODULE]); + $byId = []; + foreach ($list['data']['releases'] as $r) { $byId[$r['module_release_id']] = $r['status']; } + $this->assertSame(Tiger_Model_ModuleRelease::STATUS_REJECTED, $byId[$id] ?? null); + } +} diff --git a/tests/Integration/Git/RepoServiceTest.php b/tests/Integration/Git/RepoServiceTest.php new file mode 100644 index 00000000..f02a47db --- /dev/null +++ b/tests/Integration/Git/RepoServiceTest.php @@ -0,0 +1,212 @@ +applyGitSchema(); + $this->seedCrypto(); + + $this->seedIdentityRows('u-admin', self::ORG, 'admin'); + $this->seedIdentityRows('u-user', self::ORG, 'user'); + \Zend_Registry::set('tiger.auth.stateless', true); + + Tiger_Git_Environment::setProbe(fn () => true); // detect() without the network + Git_Service_Base::setAdapterFactory($this->seam($this->okHttp())); + } + + protected function tearDown(): void + { + Tiger_Git_Environment::setProbe(null); + Git_Service_Base::setAdapterFactory(null); + parent::tearDown(); + } + + // --- helpers --------------------------------------------------------------------------------- + + private function applyGitSchema(): void + { + if (self::$gitSchema) { return; } + $this->db->rollBack(); // DDL auto-commits; close the per-test txn around it + if (!$this->tableExists('git_repo_connection')) { + $m = require dirname(__DIR__, 3) . '/modules/git/migrations/20261021000001_create_git_tables.php'; + foreach ($m['up'] as $sql) { $this->db->query($sql); } + } + $this->db->beginTransaction(); + self::$gitSchema = true; + } + + private function seedCrypto(): void + { + $cfg = \Zend_Registry::isRegistered('Zend_Config') ? \Zend_Registry::get('Zend_Config') : new \Zend_Config([], true); + if ($cfg->readOnly()) { $cfg = new \Zend_Config($cfg->toArray(), true); } + $cfg->merge(new \Zend_Config(['tiger' => ['crypto' => ['key' => base64_encode(str_repeat("\x22", 32))]]], true)); + \Zend_Registry::set('Zend_Config', $cfg); + } + + /** A provider factory wired to a canned HTTP seam (the real credential manager resolves the token). */ + private function seam(callable $http): callable + { + return fn () => new Tiger_Git_Provider_GitHub(new Tiger_Git_Credential(), $http); + } + + private function okHttp(): callable + { + return function (string $method, string $url, array $headers, ?string $body): array { + if (str_contains($url, '/releases')) { return ['status' => 200, 'body' => '[]']; } + return ['status' => 200, 'body' => '{}']; + }; + } + + private function call(string $method, array $params = []): array + { + $svc = new Git_Service_Repo(); + $svc->$method($params); + $res = $svc->getResponse(); + $msgs = []; + foreach ((array) $res->messages as $m) { $msgs[] = (string) $m->message; } + return ['result' => (int) $res->result, 'data' => json_decode(json_encode($res->data), true) ?: [], 'messages' => $msgs]; + } + + private function connect(array $over = []): array + { + return $this->call('connect', array_merge([ + 'module_key' => self::MODULE, 'repo_owner' => 'acme', 'repo_name' => 'mod', 'token' => 'ghp_secret', + ], $over)); + } + + // --- tests ----------------------------------------------------------------------------------- + + #[Test] + public function connect_records_the_connection_and_returns_no_secret(): void + { + $this->login('u-admin', self::ORG, 'admin'); + $res = $this->connect(); + + $this->assertSame(1, $res['result'], 'connect succeeded'); + $this->assertSame(self::MODULE, $res['data']['connection']['module_key']); + $this->assertSame('acme/mod', $res['data']['connection']['slug']); + $this->assertSame(Tiger_Git_Environment::detect()['transport'], $res['data']['connection']['transport']); + + $flat = json_encode($res['data']); + $this->assertStringNotContainsString('ghp_secret', $flat, 'the token never leaves the service'); + $this->assertStringNotContainsString('credential_ref', $flat, 'the credential ref is not exposed'); + + $conn = (new Tiger_Model_GitRepoConnection())->forModule(self::ORG, self::MODULE); + $this->assertNotNull($conn, 'a connection row exists'); + $this->assertNotSame('', (string) $conn->credential_ref, 'it points at a stored credential'); + $this->assertNotNull((new Tiger_Model_GitCredential())->forModule(self::ORG, self::MODULE)); + } + + #[Test] + public function a_bad_credential_is_rejected_and_leaves_nothing(): void + { + $this->login('u-admin', self::ORG, 'admin'); + Git_Service_Base::setAdapterFactory($this->seam(function (string $m, string $url): array { + return str_contains($url, '/releases') + ? ['status' => 401, 'body' => json_encode(['message' => 'Bad credentials'])] + : ['status' => 200, 'body' => '{}']; + })); + + $res = $this->connect(); + + $this->assertSame(0, $res['result']); + $this->assertContains('git.error.credential_invalid', $res['messages']); + $this->assertNull((new Tiger_Model_GitRepoConnection())->forModule(self::ORG, self::MODULE), 'no connection was recorded'); + $this->assertNull((new Tiger_Model_GitCredential())->forModule(self::ORG, self::MODULE), 'the just-stored credential was revoked'); + } + + #[Test] + public function reconnect_after_disconnect_is_not_blocked(): void + { + $this->login('u-admin', self::ORG, 'admin'); + $this->assertSame(1, $this->connect()['result']); + $this->assertSame(1, $this->call('disconnect', ['module_key' => self::MODULE])['result']); + $this->assertNull((new Tiger_Model_GitRepoConnection())->forModule(self::ORG, self::MODULE)); + + $this->assertSame(1, $this->connect()['result'], 'the active-only unique freed the pair'); + $this->assertNotNull((new Tiger_Model_GitRepoConnection())->forModule(self::ORG, self::MODULE)); + } + + #[Test] + public function disconnect_revokes_the_credential(): void + { + $this->login('u-admin', self::ORG, 'admin'); + $this->connect(); + $ref = (string) (new Tiger_Model_GitRepoConnection())->forModule(self::ORG, self::MODULE)->credential_ref; + + $this->assertSame(1, $this->call('disconnect', ['module_key' => self::MODULE])['result']); + $this->assertNull((new Tiger_Git_Credential())->resolve($ref), 'the credential no longer resolves'); + } + + #[Test] + public function status_is_graceful_when_the_transport_cannot_do_it(): void + { + $this->login('u-admin', self::ORG, 'admin'); + $this->connect(); + // Force the API transport, where status is not yet implemented — the service must degrade, not error. + $conn = (new Tiger_Model_GitRepoConnection())->forModule(self::ORG, self::MODULE); + $conn->transport = Tiger_Git::TRANSPORT_API; + $conn->save(); + + $res = $this->call('status', ['module_key' => self::MODULE]); + $this->assertSame(1, $res['result']); + $this->assertFalse($res['data']['status_available']); + $this->assertNull($res['data']['working']); + } + + #[Test] + public function list_and_log_reflect_the_connect(): void + { + $this->login('u-admin', self::ORG, 'admin'); + $this->connect(); + + $list = $this->call('list'); + $this->assertCount(1, $list['data']['connections']); + + $log = $this->call('log', ['module_key' => self::MODULE]); + $ops = array_column($log['data']['entries'], 'operation'); + $this->assertContains('connect', $ops); + } + + #[Test] + public function a_non_admin_is_refused(): void + { + $this->login('u-user', self::ORG, 'user'); + $res = $this->connect(); + $this->assertSame(0, $res['result']); + $this->assertSame('core.api.error.not_allowed', $res['messages'][0]); + $this->assertNull((new Tiger_Model_GitRepoConnection())->forModule(self::ORG, self::MODULE)); + } +} diff --git a/tests/Unit/Git/ApiTransportReleaseTest.php b/tests/Unit/Git/ApiTransportReleaseTest.php index 98aac15f..546290a0 100644 --- a/tests/Unit/Git/ApiTransportReleaseTest.php +++ b/tests/Unit/Git/ApiTransportReleaseTest.php @@ -138,6 +138,33 @@ public function fetch_archive_refuses_a_redirect_off_the_allowlist(): void $this->assertFileDoesNotExist($this->dir . '/archive.tar.gz'); } + #[Test] + public function tag_creates_an_annotated_tag_object_then_the_ref(): void + { + $calls = []; + $http = $this->http($calls, function (string $m, string $url): array { + if ($m === 'GET' && str_contains($url, '/git/ref/heads/main')) { return ['status' => 200, 'body' => json_encode(['object' => ['sha' => 'HEADSHA']])]; } + if ($m === 'POST' && str_contains($url, '/git/tags')) { return ['status' => 201, 'body' => json_encode(['sha' => 'TAGOBJ'])]; } + if ($m === 'POST' && str_contains($url, '/git/refs')) { return ['status' => 201, 'body' => json_encode(['ref' => 'refs/tags/v1.0.0'])]; } + return ['status' => 404, 'body' => '{}']; + }); + $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $http); + + $out = $api->tag($this->tree(), 'v1.0.0'); + + $this->assertSame('v1.0.0', $out['tag']); + $this->assertSame('HEADSHA', $out['sha'], 'the returned sha is the tagged COMMIT, not the tag object'); + + $seq = array_map(fn ($c) => $c['method'] . ' ' . parse_url($c['url'], PHP_URL_PATH), $calls); + $this->assertSame([ + 'GET /repos/acme/mod/git/ref/heads/main', + 'POST /repos/acme/mod/git/tags', + 'POST /repos/acme/mod/git/refs', + ], $seq); + $this->assertSame('refs/tags/v1.0.0', json_decode($calls[2]['body'], true)['ref']); + $this->assertSame('TAGOBJ', json_decode($calls[2]['body'], true)['sha'], 'the ref points at the annotated tag object'); + } + #[Test] public function fetch_archive_refuses_a_bad_tag_before_any_call(): void { From 6e4480b071eb23154b11b4ac4a6a39e02e717938 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 21 Oct 2026 16:16:36 -0400 Subject: [PATCH 07/10] =?UTF-8?q?feat(git):=20Phase-2=20Update=20service?= =?UTF-8?q?=20=E2=80=94=20receive=20approved=20updates=20(TIGER-367)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Git_Service_Update (admin): check / apply / setPolicy / rollback — the consumer side of the module. - check: which connected modules have a newer APPROVED release (one module or the whole org); respects up-to-date and the pinned channel. - apply: installs ONLY an approved release at its exact tag (never an arbitrary branch/version) — fetchArchive (adapter) → the shared installer tail (Tiger_Module_Installer::installFromTarball, force/update-in-place: backup → extract → place → migrate → publish → record). Moves the pin and records previous_version as the rollback anchor; refuses an unapproved version and the version a rollback pinned. - setPolicy: auto | notify | pinned (+ pinned_version). - rollback: restores previous_version (which must still be approved) and PINS the version it fled, so auto-update never immediately reapplies a bad release. The archive download and the install step both sit behind Base seams (setAdapterFactory / setInstaller), so the tests need no network and never touch the modules dir. acl.ini: Git_Service_Update per privilege → admin. i18n en. Tests: 10 integration (pending/up-to-date/pinned check; apply moves the pin + installs force, refuses unapproved + pinned; setPolicy; rollback restores + pins the fled version, refuses with no previous). 36 Git integration + 1182 unit green. Completes the Phase-2 /api service surface (Repo + Release + Update). TigerPanel admin UI is the next slice. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ASauLLscjqdsNqBNsx2Typ --- CAPABILITIES.md | 2 +- modules/git/configs/acl.ini | 19 ++ modules/git/languages/en/git.php | 9 + modules/git/services/Base.php | 34 +++ modules/git/services/Update.php | 236 +++++++++++++++++ tests/Integration/Git/UpdateServiceTest.php | 267 ++++++++++++++++++++ 6 files changed, 566 insertions(+), 1 deletion(-) create mode 100644 modules/git/services/Update.php create mode 100644 tests/Integration/Git/UpdateServiceTest.php diff --git a/CAPABILITIES.md b/CAPABILITIES.md index aa88f238..93a86d77 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -363,7 +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 · `modules/git` +- **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/modules/git/configs/acl.ini b/modules/git/configs/acl.ini index 8ed71826..de1d645c 100644 --- a/modules/git/configs/acl.ini +++ b/modules/git/configs/acl.ini @@ -21,6 +21,7 @@ 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" @@ -68,6 +69,24 @@ 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" diff --git a/modules/git/languages/en/git.php b/modules/git/languages/en/git.php index 7780fd15..769b5318 100644 --- a/modules/git/languages/en/git.php +++ b/modules/git/languages/en/git.php @@ -9,6 +9,9 @@ '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.', @@ -22,6 +25,12 @@ '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.', diff --git a/modules/git/services/Base.php b/modules/git/services/Base.php index 4237d65f..ff0f9fc0 100644 --- a/modules/git/services/Base.php +++ b/modules/git/services/Base.php @@ -20,12 +20,33 @@ abstract class Git_Service_Base extends Tiger_Service_Service /** @var null|callable():Tiger_Git_Provider_Interface test seam for the provider */ protected static $adapterFactory = null; + /** @var null|callable(string,array,array):array test seam for the tarball installer */ + protected static $installer = null; + /** Swap the provider factory (tests inject a seam-wired adapter). Null restores the real one. */ public static function setAdapterFactory(?callable $factory): void { self::$adapterFactory = $factory; } + /** + * Swap the tarball-install step (tests avoid touching the modules dir). The callable mirrors + * Tiger_Module_Installer::installFromTarball($tar, $provenance, $opts). Null restores the real one. + */ + public static function setInstaller(?callable $factory): void + { + self::$installer = $factory; + } + + /** Install (update-in-place) a module from a local archive — the test seam if set, else the real installer. */ + protected function _installTarball(string $tarPath, array $provenance, array $opts): array + { + if (self::$installer !== null) { + return (self::$installer)($tarPath, $provenance, $opts); + } + return Tiger_Module_Installer::installFromTarball($tarPath, $provenance, $opts); + } + /** Signed in with an org, or an error + false. (The ACL already gated the method at dispatch.) */ protected function _signedIn(): bool { @@ -128,6 +149,19 @@ protected function _envView(array $env): array ]; } + /** 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 { 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/tests/Integration/Git/UpdateServiceTest.php b/tests/Integration/Git/UpdateServiceTest.php new file mode 100644 index 00000000..4c0346a4 --- /dev/null +++ b/tests/Integration/Git/UpdateServiceTest.php @@ -0,0 +1,267 @@ + install-seam invocations */ + private array $installs = []; + + protected function setUp(): void + { + parent::setUp(); + $this->applyGitSchema(); + $this->seedCrypto(); + $this->seedIdentityRows('u-admin', self::ORG, 'admin'); + \Zend_Registry::set('tiger.auth.stateless', true); + + Git_Service_Base::setAdapterFactory(fn () => new Tiger_Git_Provider_GitHub(new Tiger_Git_Credential(), $this->archiveHttp())); + $this->installs = []; + Git_Service_Base::setInstaller(function (string $tar, array $prov, array $opts): array { + $this->installs[] = ['tar' => $tar, 'prov' => $prov, 'opts' => $opts]; + return ['slug' => self::MODULE, 'version' => $prov['ref'] ?? null, 'updated' => true]; + }); + $this->seedConnection(); + $this->login('u-admin', self::ORG, 'admin'); + } + + protected function tearDown(): void + { + Git_Service_Base::setAdapterFactory(null); + Git_Service_Base::setInstaller(null); + parent::tearDown(); + } + + // --- helpers --------------------------------------------------------------------------------- + + private function applyGitSchema(): void + { + if (self::$gitSchema) { return; } + $this->db->rollBack(); + if (!$this->tableExists('git_repo_connection')) { + $m = require dirname(__DIR__, 3) . '/modules/git/migrations/20261021000001_create_git_tables.php'; + foreach ($m['up'] as $sql) { $this->db->query($sql); } + } + $this->db->beginTransaction(); + self::$gitSchema = true; + } + + private function seedCrypto(): void + { + $cfg = \Zend_Registry::isRegistered('Zend_Config') ? \Zend_Registry::get('Zend_Config') : new \Zend_Config([], true); + if ($cfg->readOnly()) { $cfg = new \Zend_Config($cfg->toArray(), true); } + $cfg->merge(new \Zend_Config(['tiger' => ['crypto' => ['key' => base64_encode(str_repeat("\x22", 32))]]], true)); + \Zend_Registry::set('Zend_Config', $cfg); + } + + private function seedConnection(): void + { + $cred = (new Tiger_Git_Credential())->store(self::ORG, self::MODULE, Tiger_Model_GitCredential::TYPE_TOKEN, 'ghp_x', []); + (new Tiger_Model_GitRepoConnection())->insert([ + 'org_id' => self::ORG, 'module_key' => self::MODULE, 'provider' => Tiger_Git_Provider_GitHub::PROVIDER, + 'repo_owner' => 'acme', 'repo_name' => 'mod', 'default_branch' => 'main', + 'transport' => Tiger_Git::TRANSPORT_API, 'write_enabled' => 1, 'credential_ref' => $cred['ref'], + 'status' => Tiger_Git::STATUS_CONNECTED, + ]); + } + + private function approve(string $version, string $sha): void + { + (new Tiger_Model_ModuleRelease())->insert([ + 'org_id' => self::ORG, 'module_key' => self::MODULE, 'version' => $version, 'git_tag' => $version, + 'commit_sha' => $sha, 'status' => Tiger_Model_ModuleRelease::STATUS_APPROVED, + 'submitted_at' => gmdate('Y-m-d H:i:s'), 'decided_at' => gmdate('Y-m-d H:i:s'), + ]); + } + + private function pin(array $fields): void + { + (new Tiger_Model_ModuleInstallPin())->insert(array_merge([ + 'org_id' => self::ORG, 'module_key' => self::MODULE, 'channel' => Tiger_Git::POLICY_NOTIFY, 'status' => 'active', + ], $fields)); + } + + private function archiveHttp(): callable + { + return function (string $method, string $url, array $headers, ?string $body): array { + if (str_contains($url, 'api.github.com') && str_contains($url, '/tarball/')) { + return ['status' => 302, 'headers' => ['Location' => 'https://codeload.github.com/acme/mod/tar.gz/x'], 'body' => '']; + } + if (str_contains($url, 'codeload.github.com')) { return ['status' => 200, 'body' => 'TARBALL']; } + return ['status' => 404, 'body' => '{}']; + }; + } + + private function call(string $method, array $params = []): array + { + $svc = new Git_Service_Update(); + $svc->$method($params); + $res = $svc->getResponse(); + $msgs = []; + foreach ((array) $res->messages as $m) { $msgs[] = (string) $m->message; } + return ['result' => (int) $res->result, 'data' => json_decode(json_encode($res->data), true) ?: [], 'messages' => $msgs]; + } + + private function freshPin(): ?object + { + return (new Tiger_Model_ModuleInstallPin())->forModule(self::ORG, self::MODULE); + } + + // --- check ----------------------------------------------------------------------------------- + + #[Test] + public function check_reports_a_pending_approved_update(): void + { + $this->approve('1.2.0', 'sha12'); + $this->pin(['installed_version' => '1.0.0']); + + $res = $this->call('check', ['module_key' => self::MODULE]); + $this->assertSame(1, $res['result']); + $u = $res['data']['updates'][0]; + $this->assertTrue($u['pending']); + $this->assertSame('1.2.0', $u['latest_approved_version']); + $this->assertSame('1.0.0', $u['installed_version']); + } + + #[Test] + public function check_is_not_pending_when_up_to_date(): void + { + $this->approve('1.2.0', 'sha12'); + $this->pin(['installed_version' => '1.2.0']); + $this->assertFalse($this->call('check')['data']['updates'][0]['pending']); + } + + #[Test] + public function check_respects_the_pinned_channel(): void + { + $this->approve('1.2.0', 'sha12'); + $this->pin(['installed_version' => '1.0.0', 'channel' => Tiger_Git::POLICY_PINNED]); + $this->assertFalse($this->call('check')['data']['updates'][0]['pending'], 'a pinned module is never pending'); + } + + // --- apply ----------------------------------------------------------------------------------- + + #[Test] + public function apply_installs_the_latest_approved_and_moves_the_pin(): void + { + $this->approve('1.2.0', 'sha12'); + $this->pin(['installed_version' => '1.0.0']); + + $res = $this->call('apply', ['module_key' => self::MODULE]); + $this->assertSame(1, $res['result']); + $this->assertSame('1.2.0', $res['data']['version']); + $this->assertSame('1.0.0', $res['data']['previous_version']); + + $pin = $this->freshPin(); + $this->assertSame('1.2.0', (string) $pin->installed_version); + $this->assertSame('1.0.0', (string) $pin->previous_version); + + $this->assertCount(1, $this->installs, 'the installer ran once'); + $this->assertTrue($this->installs[0]['opts']['force'], 'it updates in place'); + $this->assertSame('1.2.0', $this->installs[0]['prov']['ref']); + } + + #[Test] + public function apply_refuses_an_unapproved_version(): void + { + $this->approve('1.2.0', 'sha12'); + $this->pin(['installed_version' => '1.0.0']); + + $res = $this->call('apply', ['module_key' => self::MODULE, 'version' => '9.9.9']); + $this->assertSame(0, $res['result']); + $this->assertContains('git.error.not_approved', $res['messages']); + $this->assertCount(0, $this->installs, 'nothing was installed'); + } + + #[Test] + public function apply_refuses_the_pinned_version(): void + { + $this->approve('1.2.0', 'sha12'); + $this->pin(['installed_version' => '1.0.0', 'channel' => Tiger_Git::POLICY_PINNED, 'pinned_version' => '1.2.0']); + + $res = $this->call('apply', ['module_key' => self::MODULE, 'version' => '1.2.0']); + $this->assertSame(0, $res['result']); + $this->assertContains('git.error.version_pinned', $res['messages']); + } + + // --- setPolicy ------------------------------------------------------------------------------- + + #[Test] + public function set_policy_saves_the_channel(): void + { + $this->pin(['installed_version' => '1.0.0']); + $res = $this->call('setPolicy', ['module_key' => self::MODULE, 'channel' => Tiger_Git::POLICY_PINNED, 'pinned_version' => '1.0.0']); + $this->assertSame(1, $res['result']); + $this->assertSame(Tiger_Git::POLICY_PINNED, (string) $this->freshPin()->channel); + $this->assertSame('1.0.0', (string) $this->freshPin()->pinned_version); + } + + #[Test] + public function set_policy_refuses_an_unknown_channel(): void + { + $res = $this->call('setPolicy', ['module_key' => self::MODULE, 'channel' => 'whenever']); + $this->assertSame(0, $res['result']); + $this->assertContains('git.error.invalid_channel', $res['messages']); + } + + // --- rollback -------------------------------------------------------------------------------- + + #[Test] + public function rollback_restores_the_previous_version_and_pins_the_bad_one(): void + { + $this->approve('1.0.0', 'sha10'); + $this->approve('1.2.0', 'sha12'); + $this->pin(['installed_version' => '1.2.0', 'previous_version' => '1.0.0']); + + $res = $this->call('rollback', ['module_key' => self::MODULE]); + $this->assertSame(1, $res['result']); + $this->assertSame('1.0.0', $res['data']['version']); + $this->assertSame('1.2.0', $res['data']['pinned_version']); + + $pin = $this->freshPin(); + $this->assertSame('1.0.0', (string) $pin->installed_version); + $this->assertSame(Tiger_Git::POLICY_PINNED, (string) $pin->channel); + $this->assertSame('1.2.0', (string) $pin->pinned_version, 'the fled version is pinned so auto-update skips it'); + $this->assertCount(1, $this->installs); + } + + #[Test] + public function rollback_without_a_previous_version_is_refused(): void + { + $this->approve('1.2.0', 'sha12'); + $this->pin(['installed_version' => '1.2.0']); + $res = $this->call('rollback', ['module_key' => self::MODULE]); + $this->assertSame(0, $res['result']); + $this->assertContains('git.error.no_previous', $res['messages']); + } +} From cd8af719d1ebbf88be0d11347f9e3c848bc4d707 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 21 Oct 2026 16:37:15 -0400 Subject: [PATCH 08/10] feat(git): admin UI + MCP reach for the TigerGit services (TIGER-367) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The human interface over the same /api services an agent already drives — plus the MCP wiring so "run the module" works over API, MCP, and the UI alike. Admin UI (ADMIN.md): Git_AdminController + /git/admin, admin-nav item, admin-gated in acl.ini. One dashboard screen, client/server: the controller renders the shell (connect + submit forms for their fields + CSRF); git.admin.js draws the connections and drives every action over /api — connect, check/apply/rollback + policy, tag → submit → QA approve/reject, re-check, activity log, disconnect. Bootstrap/PUMA only (no inline script/style), TigerButton busy states, TigerDOM feedback, TigerModal confirm on disconnect, escaped output, aria-labels on icon buttons. API + MCP: the /api surface already existed (the services); this makes it MCP-runnable out of the box — `git` added to Tiger_Mcp_Token::DEFAULT_MODULES (admin-gated, so it only surfaces for an admin-role token) so all 14 git tools reflect into tools/list and tools/call proxies to /api with no new code. The two form-backed tools get typed MCP inputSchemas via @apiRequest (Git_Form_Connect / Git_Form_Submit), like the other curated create/edit tools. Tests: +1 controller dispatch test (shell renders, forms handed to the view). 37 Git integration + 1182 unit green; Mcp suite green with git in the default set. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ASauLLscjqdsNqBNsx2Typ --- library/Tiger/Mcp/Token.php | 2 +- modules/git/assets/js/git.admin.js | 335 ++++++++++++++++++ modules/git/configs/acl.ini | 6 + modules/git/configs/navigation.ini | 12 + modules/git/controllers/AdminController.php | 39 ++ modules/git/languages/en/git.php | 25 ++ modules/git/services/Release.php | 1 + modules/git/services/Repo.php | 1 + modules/git/views/scripts/admin/index.phtml | 140 ++++++++ tests/Integration/Git/AdminControllerTest.php | 33 ++ 10 files changed, 593 insertions(+), 1 deletion(-) create mode 100644 modules/git/assets/js/git.admin.js create mode 100644 modules/git/configs/navigation.ini create mode 100644 modules/git/controllers/AdminController.php create mode 100644 modules/git/views/scripts/admin/index.phtml create mode 100644 tests/Integration/Git/AdminControllerTest.php 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/modules/git/assets/js/git.admin.js b/modules/git/assets/js/git.admin.js new file mode 100644 index 00000000..cb66f1e5 --- /dev/null +++ b/modules/git/assets/js/git.admin.js @@ -0,0 +1,335 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers. +// +// TigerGit admin dashboard — the client for Git_Service_Repo / _Release / _Update over /api +// (TIGER-334 / TIGER-367). The same surface an agent drives over /api or MCP; this just draws it. +// House rules: no page-POST (every call is /api), TigerButton for busy states, TigerDOM for feedback, +// TigerModal for confirms, all interpolation escaped. +(function () { + 'use strict'; + + var fb = document.getElementById('git-feedback'); + + function esc(s) { + var d = document.createElement('div'); + d.textContent = (s === null || s === undefined) ? '' : String(s); + return d.innerHTML; + } + + function notify(msg, type, where) { + TigerDOM.notify(where || fb, msg, { type: type || 'info' }); + } + + // One /api POST (mutations must be POST — the SameSite defense; reads ride POST fine too). + function post(fields) { + var body = new URLSearchParams(); + Object.keys(fields).forEach(function (k) { + if (fields[k] !== undefined && fields[k] !== null) { body.set(k, fields[k]); } + }); + return fetch('/api', { + method: 'POST', + headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString() + }).then(function (r) { return r.json(); }).catch(function () { + return { result: 0, messages: [{ message: 'Network error — please try again.', class: 'error' }] }; + }); + } + + // A form-backed POST (carries the form's CSRF token + fields) plus the message routing. + function postForm(form, extra) { + var body = new URLSearchParams(new FormData(form)); + Object.keys(extra).forEach(function (k) { body.set(k, extra[k]); }); + return fetch('/api', { + method: 'POST', + headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString() + }).then(function (r) { return r.json(); }).catch(function () { + return { result: 0, messages: [{ message: 'Network error — please try again.', class: 'error' }] }; + }); + } + + function flash(res, where) { + (res && res.messages || []).forEach(function (m) { notify(m.message, m.class, where); }); + } + + function git(service, method, params) { + var p = params || {}; + p.module = 'git'; p.service = service; p.method = method; + return post(p); + } + + // ---- connections list ------------------------------------------------------------------- + + var list = document.getElementById('git-connections'); + + function badge(text, cls) { return '' + 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 index de1d645c..95da628d 100644 --- a/modules/git/configs/acl.ini +++ b/modules/git/configs/acl.ini @@ -19,12 +19,18 @@ ; "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" 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/languages/en/git.php b/modules/git/languages/en/git.php index 769b5318..655d5367 100644 --- a/modules/git/languages/en/git.php +++ b/modules/git/languages/en/git.php @@ -3,6 +3,31 @@ // Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers. /** TigerGit module — English strings (TIGER-334 / TIGER-367). Sibling locales follow with the UI. */ return [ + // admin screen + 'git.nav.label' => '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.', diff --git a/modules/git/services/Release.php b/modules/git/services/Release.php index b3e797bf..091caaa0 100644 --- a/modules/git/services/Release.php +++ b/modules/git/services/Release.php @@ -55,6 +55,7 @@ public function tag(array $params): void * 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 { diff --git a/modules/git/services/Repo.php b/modules/git/services/Repo.php index 98f9f73a..11a5f64d 100644 --- a/modules/git/services/Repo.php +++ b/modules/git/services/Repo.php @@ -25,6 +25,7 @@ class Git_Service_Repo extends Git_Service_Base * transport, verifies read access against the real repo, then records the connection. * * @param array $params module_key, repo_owner, repo_name, token, default_branch?, write_enabled? + * @apiRequest Git_Form_Connect * @return void */ public function connect(array $params): void 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 @@ +/