From b805bcda7e58c5b24c03c7327aaffc30d9d97ae7 Mon Sep 17 00:00:00 2001 From: ReBoticsAI Date: Tue, 14 Jul 2026 20:40:36 -0600 Subject: [PATCH 1/3] Route GitHub mutations through ObjectType kernel contracts. --- .github/workflows/ci.yml | 42 +++ godmode.plugin.json | 1 + package-lock.json | 599 ++++++++++++++++++++++++++++++++++++ package.json | 16 +- src/adapters.ts | 498 ++++++++++++++++++++++++++++++ src/bridge.ts | 450 ++++++++++++++++++--------- src/gh-util.ts | 228 +++++++++++--- test/adapters.test.ts | 172 +++++++++++ test/contracts.test.ts | 42 +++ test/manifest.test.ts | 31 ++ test/semantic-tools.test.ts | 145 +++++++++ tsconfig.json | 3 +- tsconfig.test.json | 8 + 13 files changed, 2055 insertions(+), 180 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 package-lock.json create mode 100644 src/adapters.ts create mode 100644 test/adapters.test.ts create mode 100644 test/contracts.test.ts create mode 100644 test/manifest.test.ts create mode 100644 test/semantic-tools.test.ts create mode 100644 tsconfig.test.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..af94f9b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Check out plugin + uses: actions/checkout@v4 + with: + path: plugin + - name: Check out current GodMode API + uses: actions/checkout@v4 + with: + repository: ReBoticsAI/GodMode + ref: feat/objecttype-kernel-migration + path: GodMode + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: plugin/package-lock.json + - name: Build local kernel packages + working-directory: GodMode + run: | + npm ci + npm run build -w @godmode/kernel + npm run build -w @godmode/plugin-api + - name: Install plugin dependencies + working-directory: plugin + run: npm ci + - name: Validate migration + working-directory: plugin + run: npm run validate diff --git a/godmode.plugin.json b/godmode.plugin.json index c4295ff..ecf7645 100644 --- a/godmode.plugin.json +++ b/godmode.plugin.json @@ -3,5 +3,6 @@ "version": "0.1.0", "name": "GitHub", "engine": "^0.1.0", + "kernelApiVersion": 1, "bridge": { "entry": "dist/bridge.js" } } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ed402f6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,599 @@ +{ + "name": "@godmode-plugin/github", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@godmode-plugin/github", + "version": "0.1.0", + "dependencies": { + "@godmode/kernel": "file:../GodMode/packages/kernel", + "@godmode/plugin-api": "file:../GodMode/packages/plugin-api" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "tsx": "^4.0.0", + "typescript": "^5.7.0" + } + }, + "../GodMode/packages/kernel": { + "name": "@godmode/kernel", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.3" + } + }, + "../GodMode/packages/plugin-api": { + "name": "@godmode/plugin-api", + "version": "0.1.0", + "dependencies": { + "@godmode/kernel": "*", + "express": "^4.21.2" + }, + "devDependencies": { + "@types/express": "^5.0.0", + "@types/react": "^19.0.2", + "typescript": "^5.7.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@godmode/kernel": { + "resolved": "../GodMode/packages/kernel", + "link": true + }, + "node_modules/@godmode/plugin-api": { + "resolved": "../GodMode/packages/plugin-api", + "link": true + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json index e193097..09db713 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,18 @@ "type": "module", "description": "GitHub PR/CI tools for GodMode Intelligence via the gh CLI.", "scripts": { - "build": "echo Use GodMode build_plugin (Bridge esbuild) or npx esbuild locally" + "build": "tsc", + "typecheck": "tsc --noEmit && tsc -p tsconfig.test.json", + "test": "node --import tsx --test test/*.test.ts", + "validate": "npm run typecheck && npm test && npm run build" }, - "dependencies": {}, - "devDependencies": {} + "dependencies": { + "@godmode/kernel": "file:../GodMode/packages/kernel", + "@godmode/plugin-api": "file:../GodMode/packages/plugin-api" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "tsx": "^4.0.0", + "typescript": "^5.7.0" + } } diff --git a/src/adapters.ts b/src/adapters.ts new file mode 100644 index 0000000..c4ebfb5 --- /dev/null +++ b/src/adapters.ts @@ -0,0 +1,498 @@ +import path from "node:path"; +import type { + PluginRecordAdapter, + PluginRecordContext, +} from "@godmode/plugin-api"; +import type { ActionDef, ObjectTypeDef, RecordData } from "@godmode/kernel"; +import { + GitHubActionError, + requireGhSuccess, + resolveWorkingRoot, + runGh, + type CommandRunner, +} from "./gh-util.js"; + +const PLUGIN_ID = "godmode-plugin-github"; +const REPOSITORY_ADAPTER_ID = `${PLUGIN_ID}.repository`; +const PULL_REQUEST_ADAPTER_ID = `${PLUGIN_ID}.pull-request`; + +const errorSchema = { + type: "object", + required: ["code", "message", "retryable"], + properties: { + code: { type: "string" }, + message: { type: "string" }, + retryable: { type: "boolean" }, + details: {}, + }, + additionalProperties: false, +}; + +function objectSchema( + properties: Record, + required: string[] = [] +): Record { + return { + type: "object", + properties, + required: required.length ? required : undefined, + additionalProperties: false, + }; +} + +function event(type: string) { + return [ + { + type, + schema: { + type: "object", + required: ["objectType", "recordId", "action", "result"], + additionalProperties: true, + }, + }, + ]; +} + +const repositoryOutput = objectSchema( + { + cwd: { type: "string" }, + ok: { type: "boolean", const: true }, + url: { type: "string" }, + stdout: { type: "string" }, + }, + ["cwd", "ok", "url", "stdout"] +); + +const mutationContract = { + execution: "sync" as const, + contractVersion: 1, + confirmation: { required: true, ttlSeconds: 300 }, + idempotency: { required: true, ttlSeconds: 86_400 }, + retry: { + maxAttempts: 1, + retryableErrorCodes: [ + "GITHUB_CLI_FAILED", + "GITHUB_CLI_UNAVAILABLE", + "GITHUB_ACTION_TIMEOUT", + ], + }, + errorSchema, +}; + +const repositoryActions: ActionDef[] = [ + { + name: "create_pull_request", + label: "Create pull request", + description: "Create a pull request from the repository's current branch.", + target: "record", + effect: "external", + roles: ["editor", "owner", "intelligence"], + ...mutationContract, + timeoutMs: 300_000, + inputSchema: objectSchema( + { + title: { type: "string", minLength: 1, maxLength: 256 }, + body: { type: "string", minLength: 1, maxLength: 100_000 }, + base: { type: "string", minLength: 1, maxLength: 255 }, + draft: { type: "boolean" }, + }, + ["title", "body"] + ), + outputSchema: repositoryOutput, + events: event("github.pull_request.created"), + }, + { + name: "create_issue", + label: "Create issue", + description: "Create an issue in the repository.", + target: "record", + effect: "external", + roles: ["editor", "owner", "intelligence"], + ...mutationContract, + timeoutMs: 180_000, + inputSchema: objectSchema( + { + title: { type: "string", minLength: 1, maxLength: 256 }, + body: { type: "string", maxLength: 100_000 }, + labels: { + type: "array", + maxItems: 50, + items: { type: "string", minLength: 1, maxLength: 100 }, + }, + }, + ["title"] + ), + outputSchema: repositoryOutput, + events: event("github.issue.created"), + }, +]; + +const pullRequestActions: ActionDef[] = [ + { + name: "comment", + label: "Comment on pull request", + description: "Add a comment to a pull request.", + target: "record", + effect: "external", + roles: ["editor", "owner", "intelligence"], + ...mutationContract, + timeoutMs: 180_000, + inputSchema: objectSchema( + { body: { type: "string", minLength: 1, maxLength: 100_000 } }, + ["body"] + ), + outputSchema: objectSchema( + { + cwd: { type: "string" }, + ok: { type: "boolean", const: true }, + stdout: { type: "string" }, + }, + ["cwd", "ok", "stdout"] + ), + events: event("github.pull_request.commented"), + }, + { + name: "merge", + label: "Merge pull request", + description: + "Merge a non-draft pull request only when GitHub reports it mergeable and all checks are successful.", + target: "record", + effect: "destructive", + roles: ["owner", "intelligence"], + ...mutationContract, + timeoutMs: 300_000, + inputSchema: objectSchema({ + method: { type: "string", enum: ["squash", "merge", "rebase"] }, + deleteBranch: { type: "boolean" }, + }), + outputSchema: objectSchema( + { + cwd: { type: "string" }, + ok: { type: "boolean", const: true }, + stdout: { type: "string" }, + readiness: { + type: "object", + required: ["ready", "checks"], + properties: { + ready: { type: "boolean", const: true }, + checks: { type: "integer", minimum: 0 }, + }, + additionalProperties: false, + }, + }, + ["cwd", "ok", "stdout", "readiness"] + ), + events: event("github.pull_request.merged"), + }, +]; + +export const githubRepositoryObjectType: ObjectTypeDef = { + name: "GitHubRepository", + label: "GitHub Repository", + labelPlural: "GitHub Repositories", + description: "A GitHub repository rooted within the active coding root.", + pluginId: PLUGIN_ID, + module: "engineering", + accessPolicy: "tenant-member", + contractVersion: 1, + database: "tenant", + storage: { kind: "adapter", adapterId: REPOSITORY_ADAPTER_ID }, + fields: [ + { name: "id", label: "ID", fieldType: "Data", inForm: false }, + { name: "path", label: "Path", fieldType: "Data", inForm: false }, + { name: "name", label: "Name", fieldType: "ReadOnly", inForm: false }, + ], + permissions: [ + { role: "viewer", read: true }, + { role: "editor", read: true }, + { role: "owner", read: true }, + { role: "intelligence", read: true }, + ], + actions: repositoryActions, +}; + +export const pullRequestObjectType: ObjectTypeDef = { + name: "PullRequest", + label: "Pull Request", + labelPlural: "Pull Requests", + description: "A GitHub pull request scoped to a repository coding root.", + pluginId: PLUGIN_ID, + module: "engineering", + accessPolicy: "tenant-member", + contractVersion: 1, + database: "tenant", + storage: { kind: "adapter", adapterId: PULL_REQUEST_ADAPTER_ID }, + fields: [ + { name: "id", label: "ID", fieldType: "Data", inForm: false }, + { + name: "repository_path", + label: "Repository path", + fieldType: "Data", + inForm: false, + }, + { name: "number", label: "Number", fieldType: "Int", inForm: false }, + ], + permissions: [ + { role: "viewer", read: true }, + { role: "editor", read: true }, + { role: "owner", read: true }, + { role: "intelligence", read: true }, + ], + actions: pullRequestActions, +}; + +function requiredString(input: RecordData, key: string): string { + const value = input[key]; + if (typeof value !== "string" || !value.trim()) { + throw new GitHubActionError( + 400, + "GITHUB_INVALID_INPUT", + `${key} is required` + ); + } + return value.trim(); +} + +export function pullRequestId(cwd: string, number: number): string { + return Buffer.from(JSON.stringify([cwd, number]), "utf8").toString("base64url"); +} + +export function parsePullRequestId(id: string): { + cwd: string; + number: number; +} { + try { + const value = JSON.parse( + Buffer.from(id, "base64url").toString("utf8") + ) as unknown; + if ( + !Array.isArray(value) || + value.length !== 2 || + typeof value[0] !== "string" || + !Number.isSafeInteger(value[1]) || + Number(value[1]) <= 0 + ) { + throw new Error("invalid"); + } + return { cwd: value[0], number: Number(value[1]) }; + } catch { + throw new GitHubActionError( + 400, + "GITHUB_INVALID_PULL_REQUEST_ID", + "Invalid pull request identifier" + ); + } +} + +type PullRequestView = { + state?: string; + isDraft?: boolean; + mergeable?: string; + statusCheckRollup?: Array> | null; +}; + +export function assertMergeReady(view: PullRequestView): { + ready: true; + checks: number; +} { + if (view.state !== "OPEN") { + throw new GitHubActionError( + 409, + "GITHUB_PULL_REQUEST_NOT_OPEN", + "Pull request is not open" + ); + } + if (view.isDraft) { + throw new GitHubActionError( + 409, + "GITHUB_PULL_REQUEST_DRAFT", + "Draft pull requests cannot be merged" + ); + } + if (view.mergeable !== "MERGEABLE") { + throw new GitHubActionError( + 409, + "GITHUB_PULL_REQUEST_NOT_MERGEABLE", + `Pull request is not mergeable (${view.mergeable ?? "UNKNOWN"})`, + { retryable: view.mergeable === "UNKNOWN" } + ); + } + const checks = view.statusCheckRollup ?? []; + const failing = checks.filter((check) => { + const value = String( + check.conclusion ?? check.state ?? check.status ?? "" + ).toUpperCase(); + return !["SUCCESS", "NEUTRAL", "SKIPPED"].includes(value); + }); + if (failing.length) { + throw new GitHubActionError( + 409, + "GITHUB_CHECKS_NOT_READY", + `${failing.length} required check(s) are not successful`, + { retryable: true } + ); + } + return { ready: true, checks: checks.length }; +} + +function repositoryPath(id: string, ctx: PluginRecordContext): string { + return resolveWorkingRoot(ctx, id); +} + +export function createGithubAdapters( + runner: CommandRunner = runGh +): { + repository: PluginRecordAdapter; + pullRequest: PluginRecordAdapter; +} { + const repository: PluginRecordAdapter = { + get(id, ctx) { + const cwd = repositoryPath(id, ctx); + return { + id: cwd, + objectType: "GitHubRepository", + data: { id: cwd, path: cwd, name: path.basename(cwd) }, + }; + }, + actions: { + async create_pull_request(id, input, ctx) { + const cwd = repositoryPath(id, ctx); + const args = [ + "pr", + "create", + "--title", + requiredString(input, "title"), + "--body", + requiredString(input, "body"), + ]; + if (typeof input.base === "string" && input.base.trim()) { + args.push("--base", input.base.trim()); + } + if (input.draft === true) args.push("--draft"); + const result = requireGhSuccess( + await runner(cwd, args, { + timeoutMs: 300_000, + signal: ctx.signal, + }), + "gh pr create failed" + ); + return { + cwd, + ok: true, + url: result.stdout.trim(), + stdout: result.stdout, + }; + }, + async create_issue(id, input, ctx) { + const cwd = repositoryPath(id, ctx); + const args = ["issue", "create", "--title", requiredString(input, "title")]; + if (typeof input.body === "string" && input.body) { + args.push("--body", input.body); + } + if (Array.isArray(input.labels)) { + for (const label of input.labels) { + if (typeof label === "string" && label.trim()) { + args.push("--label", label.trim()); + } + } + } + const result = requireGhSuccess( + await runner(cwd, args, { + timeoutMs: 180_000, + signal: ctx.signal, + }), + "gh issue create failed" + ); + return { + cwd, + ok: true, + url: result.stdout.trim(), + stdout: result.stdout, + }; + }, + }, + }; + + const pullRequest: PluginRecordAdapter = { + get(id, ctx) { + const parsed = parsePullRequestId(id); + const cwd = resolveWorkingRoot(ctx, parsed.cwd); + return { + id: pullRequestId(cwd, parsed.number), + objectType: "PullRequest", + data: { + id: pullRequestId(cwd, parsed.number), + repository_path: cwd, + number: parsed.number, + }, + }; + }, + actions: { + async comment(id, input, ctx) { + const parsed = parsePullRequestId(id); + const cwd = resolveWorkingRoot(ctx, parsed.cwd); + const result = requireGhSuccess( + await runner( + cwd, + [ + "pr", + "comment", + String(parsed.number), + "--body", + requiredString(input, "body"), + ], + { timeoutMs: 180_000, signal: ctx.signal } + ), + "gh pr comment failed" + ); + return { cwd, ok: true, stdout: result.stdout }; + }, + async merge(id, input, ctx) { + const parsed = parsePullRequestId(id); + const cwd = resolveWorkingRoot(ctx, parsed.cwd); + const viewResult = requireGhSuccess( + await runner( + cwd, + [ + "pr", + "view", + String(parsed.number), + "--json", + "state,isDraft,mergeable,statusCheckRollup", + ], + { timeoutMs: 180_000, signal: ctx.signal } + ), + "gh pr view failed" + ); + let view: PullRequestView; + try { + view = JSON.parse(viewResult.stdout) as PullRequestView; + } catch { + throw new GitHubActionError( + 502, + "GITHUB_INVALID_RESPONSE", + "gh pr view returned invalid JSON", + { retryable: true } + ); + } + const readiness = assertMergeReady(view); + const method = + input.method === "merge" || input.method === "rebase" + ? input.method + : "squash"; + const args = ["pr", "merge", String(parsed.number), `--${method}`]; + if (input.deleteBranch !== false) args.push("--delete-branch"); + const result = requireGhSuccess( + await runner(cwd, args, { + timeoutMs: 300_000, + signal: ctx.signal, + }), + "gh pr merge failed" + ); + return { cwd, ok: true, stdout: result.stdout, readiness }; + }, + }, + }; + + return { repository, pullRequest }; +} diff --git a/src/bridge.ts b/src/bridge.ts index 61d30f4..182eaa1 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -1,219 +1,389 @@ -import type { GodModePluginRegister } from "@godmode/plugin-api"; -import { resolveWorkingRoot, runGh } from "./gh-util.js"; +import { randomUUID } from "node:crypto"; +import { + KERNEL_CLIENT_API_VERSION, + type GodModePluginApi, + type GodModePluginRegister, + type PluginBootContext, + type PluginKernelClient, + type PluginRecordContext, + type PluginTenantContext, + type PluginToolDef, +} from "@godmode/plugin-api"; +import { + createGithubAdapters, + githubRepositoryObjectType, + pullRequestId, + pullRequestObjectType, +} from "./adapters.js"; +import { + requireGhSuccess, + resolveWorkingRoot, + runGh, +} from "./gh-util.js"; -function str(v: unknown): string { - return typeof v === "string" ? v : ""; +function str(value: unknown): string { + return typeof value === "string" ? value : ""; } -export const register: GodModePluginRegister = (api) => { - api.tools.register([ +export function requireVersionedKernelClient( + kernel: PluginKernelClient +): PluginKernelClient { + const provided = (kernel as { apiVersion?: unknown } | undefined)?.apiVersion; + if (provided !== KERNEL_CLIENT_API_VERSION) { + throw new Error( + `GitHub plugin requires kernel client API version ${KERNEL_CLIENT_API_VERSION}; host provided ${String( + provided ?? "no version" + )}` + ); + } + return kernel; +} + +function integer(value: unknown, label: string): number { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error(`${label} must be a positive integer`); + } + return parsed; +} + +function actionContext( + args: Record, + ctx: PluginBootContext & + PluginTenantContext & { + requestId?: string; + confirmationId?: string; + signal?: AbortSignal; + } +): PluginRecordContext { + const activeAgentId = + typeof ctx.activeAgentId === "string" && ctx.activeAgentId + ? ctx.activeAgentId + : "intelligence"; + return { + tenantId: String(ctx.tenantId ?? ""), + userId: typeof ctx.userId === "string" ? ctx.userId : undefined, + activeAgentId, + activeSubtaskCardId: + typeof ctx.activeSubtaskCardId === "string" + ? ctx.activeSubtaskCardId + : undefined, + activeTaskCardId: + typeof ctx.activeTaskCardId === "string" + ? ctx.activeTaskCardId + : undefined, + role: activeAgentId === "intelligence" ? "intelligence" : "editor", + source: "agent", + requestId: + typeof ctx.requestId === "string" ? ctx.requestId : randomUUID(), + idempotencyKey: + str(args.idempotencyKey).trim() || + (typeof ctx.requestId === "string" ? ctx.requestId : randomUUID()), + confirmationId: + typeof ctx.confirmationId === "string" ? ctx.confirmationId : undefined, + signal: ctx.signal instanceof AbortSignal ? ctx.signal : undefined, + }; +} + +function kernelConfirmationId(error: unknown): string | undefined { + if (!error || typeof error !== "object") return undefined; + const candidate = error as { + status?: unknown; + code?: unknown; + details?: { confirmationId?: unknown }; + }; + if ( + candidate.status !== 428 && + candidate.code !== "KERNEL_CONFIRMATION_REQUIRED" + ) { + return undefined; + } + return typeof candidate.details?.confirmationId === "string" + ? candidate.details.confirmationId + : undefined; +} + +async function runConfirmedAction( + kernel: PluginKernelClient, + objectType: string, + action: string, + input: Record, + context: PluginRecordContext, + id: string +): Promise { + try { + return await kernel.runAction( + objectType, + action, + input, + context, + id + ); + } catch (error) { + const confirmationId = kernelConfirmationId(error); + if (!confirmationId) throw error; + return kernel.runAction( + objectType, + action, + input, + { ...context, confirmationId }, + id + ); + } +} + +const cwdProperty = { + cwd: { + type: "string", + description: + "Repository directory relative to the active coding root (default: coding root).", + }, +}; + +const idempotencyProperty = { + idempotencyKey: { + type: "string", + description: + "Stable retry key. Defaults to the active tool request identifier.", + }, +}; + +export function createSemanticTools(api: GodModePluginApi): PluginToolDef[] { + const kernel = requireVersionedKernelClient(api.kernel); + return [ { - name: "gh_pr_list", - description: "List pull requests for the repo at the coding root.", - mode: "auto", + name: "gh_pr_create", + description: + "Create a pull request through the GitHubRepository kernel action.", + mode: "confirm", parameters: { type: "object", properties: { - cwd: { type: "string" }, - state: { - type: "string", - enum: ["open", "closed", "merged", "all"], - description: "Default open", - }, - limit: { type: "number" }, + ...cwdProperty, + title: { type: "string" }, + body: { type: "string" }, + base: { type: "string" }, + draft: { type: "boolean" }, + ...idempotencyProperty, }, + required: ["title", "body"], + additionalProperties: false, }, handler: async (args, ctx) => { const cwd = resolveWorkingRoot(ctx, args.cwd); - const state = str(args.state) || "open"; - const limit = Math.min(50, Math.max(1, Number(args.limit ?? 20))); - const r = await runGh(cwd, [ - "pr", - "list", - "--state", - state, - "--limit", - String(limit), - "--json", - "number,title,url,headRefName,isDraft,mergeable,state", - ]); - if (r.code !== 0) throw new Error(r.stderr || r.stdout || "gh pr list failed"); - let items: unknown = r.stdout; - try { - items = JSON.parse(r.stdout); - } catch { - /* keep raw */ - } - return { cwd, pullRequests: items }; + return runConfirmedAction( + kernel, + "GitHubRepository", + "create_pull_request", + { + title: str(args.title).trim(), + body: str(args.body).trim(), + ...(str(args.base).trim() ? { base: str(args.base).trim() } : {}), + ...(args.draft === true ? { draft: true } : {}), + }, + actionContext(args, ctx), + cwd + ); }, }, { - name: "gh_pr_view", - description: "View a pull request by number (JSON).", - mode: "auto", + name: "gh_pr_comment", + description: "Comment through the PullRequest kernel action.", + mode: "confirm", parameters: { type: "object", properties: { - cwd: { type: "string" }, - number: { type: "number", description: "PR number" }, + ...cwdProperty, + number: { type: "integer", minimum: 1 }, + body: { type: "string" }, + ...idempotencyProperty, }, - required: ["number"], + required: ["number", "body"], + additionalProperties: false, }, handler: async (args, ctx) => { const cwd = resolveWorkingRoot(ctx, args.cwd); - const num = Number(args.number); - if (!Number.isFinite(num)) throw new Error("number required"); - const r = await runGh(cwd, [ - "pr", - "view", - String(num), - "--json", - "number,title,body,url,state,mergeable,statusCheckRollup,headRefName,baseRefName,isDraft", - ]); - if (r.code !== 0) throw new Error(r.stderr || r.stdout || "gh pr view failed"); - return { cwd, pr: JSON.parse(r.stdout) }; + return runConfirmedAction( + kernel, + "PullRequest", + "comment", + { body: str(args.body).trim() }, + actionContext(args, ctx), + pullRequestId(cwd, integer(args.number, "number")) + ); }, }, { - name: "gh_pr_checks", - description: "Show CI checks for a pull request.", - mode: "auto", + name: "gh_pr_merge", + description: + "Merge through the PullRequest kernel action after merge-readiness checks.", + mode: "confirm", parameters: { type: "object", properties: { - cwd: { type: "string" }, - number: { type: "number", description: "PR number (optional = current branch PR)" }, + ...cwdProperty, + number: { type: "integer", minimum: 1 }, + method: { type: "string", enum: ["squash", "merge", "rebase"] }, + deleteBranch: { type: "boolean" }, + ...idempotencyProperty, }, + required: ["number"], + additionalProperties: false, }, handler: async (args, ctx) => { const cwd = resolveWorkingRoot(ctx, args.cwd); - const ghArgs = ["pr", "checks"]; - if (Number.isFinite(Number(args.number))) ghArgs.push(String(Number(args.number))); - const r = await runGh(cwd, ghArgs); - return { - cwd, - code: r.code, - checks: r.stdout, - stderr: r.stderr || undefined, - ok: r.code === 0, - }; + return runConfirmedAction( + kernel, + "PullRequest", + "merge", + { + ...(str(args.method) ? { method: str(args.method) } : {}), + ...(typeof args.deleteBranch === "boolean" + ? { deleteBranch: args.deleteBranch } + : {}), + }, + actionContext(args, ctx), + pullRequestId(cwd, integer(args.number, "number")) + ); }, }, { - name: "gh_pr_create", - description: - "Create a pull request for the current branch. Provide title and body (Summary + Test plan).", + name: "gh_issue_create", + description: "Create an issue through the GitHubRepository kernel action.", mode: "confirm", parameters: { type: "object", properties: { - cwd: { type: "string" }, + ...cwdProperty, title: { type: "string" }, body: { type: "string" }, - base: { type: "string", description: "Base branch (default repo default)" }, - draft: { type: "boolean" }, + labels: { type: "array", items: { type: "string" } }, + ...idempotencyProperty, }, - required: ["title", "body"], + required: ["title"], + additionalProperties: false, }, handler: async (args, ctx) => { const cwd = resolveWorkingRoot(ctx, args.cwd); - const title = str(args.title).trim(); - const body = str(args.body).trim(); - if (!title || !body) throw new Error("title and body required"); - const ghArgs = ["pr", "create", "--title", title, "--body", body]; - if (str(args.base)) ghArgs.push("--base", str(args.base)); - if (args.draft === true) ghArgs.push("--draft"); - const r = await runGh(cwd, ghArgs, { timeoutMs: 300_000 }); - if (r.code !== 0) throw new Error(r.stderr || r.stdout || "gh pr create failed"); - return { cwd, ok: true, url: r.stdout.trim(), stdout: r.stdout }; + return runConfirmedAction( + kernel, + "GitHubRepository", + "create_issue", + { + title: str(args.title).trim(), + ...(typeof args.body === "string" ? { body: args.body } : {}), + ...(Array.isArray(args.labels) ? { labels: args.labels } : {}), + }, + actionContext(args, ctx), + cwd + ); }, }, + ]; +} + +function createReadTools(): PluginToolDef[] { + return [ { - name: "gh_pr_comment", - description: "Add a comment on a pull request.", - mode: "confirm", + name: "gh_pr_list", + description: "List pull requests for the repo at the coding root.", + mode: "auto", parameters: { type: "object", properties: { - cwd: { type: "string" }, - number: { type: "number" }, - body: { type: "string" }, + ...cwdProperty, + state: { + type: "string", + enum: ["open", "closed", "merged", "all"], + }, + limit: { type: "integer", minimum: 1, maximum: 50 }, }, - required: ["number", "body"], + additionalProperties: false, }, handler: async (args, ctx) => { const cwd = resolveWorkingRoot(ctx, args.cwd); - const num = Number(args.number); - const body = str(args.body).trim(); - if (!Number.isFinite(num) || !body) throw new Error("number and body required"); - const r = await runGh(cwd, ["pr", "comment", String(num), "--body", body]); - if (r.code !== 0) throw new Error(r.stderr || r.stdout || "gh pr comment failed"); - return { cwd, ok: true, stdout: r.stdout }; + const state = str(args.state) || "open"; + const limit = Math.min(50, Math.max(1, Number(args.limit ?? 20))); + const result = requireGhSuccess( + await runGh(cwd, [ + "pr", + "list", + "--state", + state, + "--limit", + String(limit), + "--json", + "number,title,url,headRefName,isDraft,mergeable,state", + ]), + "gh pr list failed" + ); + return { cwd, pullRequests: JSON.parse(result.stdout) }; }, }, { - name: "gh_pr_merge", - description: - "Merge a pull request. Prefer only after gh_pr_checks are green. Default merge method: squash.", - mode: "confirm", + name: "gh_pr_view", + description: "View a pull request by number.", + mode: "auto", parameters: { type: "object", properties: { - cwd: { type: "string" }, - number: { type: "number" }, - method: { - type: "string", - enum: ["squash", "merge", "rebase"], - description: "Default squash", - }, - deleteBranch: { type: "boolean", description: "Default true" }, + ...cwdProperty, + number: { type: "integer", minimum: 1 }, }, required: ["number"], + additionalProperties: false, }, handler: async (args, ctx) => { const cwd = resolveWorkingRoot(ctx, args.cwd); - const num = Number(args.number); - if (!Number.isFinite(num)) throw new Error("number required"); - const method = str(args.method) || "squash"; - const ghArgs = ["pr", "merge", String(num), `--${method}`]; - if (args.deleteBranch !== false) ghArgs.push("--delete-branch"); - const r = await runGh(cwd, ghArgs, { timeoutMs: 300_000 }); - if (r.code !== 0) throw new Error(r.stderr || r.stdout || "gh pr merge failed"); - return { cwd, ok: true, stdout: r.stdout }; + const number = integer(args.number, "number"); + const result = requireGhSuccess( + await runGh(cwd, [ + "pr", + "view", + String(number), + "--json", + "number,title,body,url,state,mergeable,statusCheckRollup,headRefName,baseRefName,isDraft", + ]), + "gh pr view failed" + ); + return { cwd, pr: JSON.parse(result.stdout) }; }, }, { - name: "gh_issue_create", - description: "Create a GitHub issue in the current repository.", - mode: "confirm", + name: "gh_pr_checks", + description: "Show CI checks for a pull request.", + mode: "auto", parameters: { type: "object", properties: { - cwd: { type: "string" }, - title: { type: "string" }, - body: { type: "string" }, - labels: { - type: "array", - items: { type: "string" }, - }, + ...cwdProperty, + number: { type: "integer", minimum: 1 }, }, - required: ["title"], + additionalProperties: false, }, handler: async (args, ctx) => { const cwd = resolveWorkingRoot(ctx, args.cwd); - const title = str(args.title).trim(); - if (!title) throw new Error("title required"); - const ghArgs = ["issue", "create", "--title", title]; - if (str(args.body)) ghArgs.push("--body", str(args.body)); - if (Array.isArray(args.labels)) { - for (const lab of args.labels) { - if (typeof lab === "string" && lab.trim()) ghArgs.push("--label", lab.trim()); - } + const ghArgs = ["pr", "checks"]; + if (args.number != null) { + ghArgs.push(String(integer(args.number, "number"))); } - const r = await runGh(cwd, ghArgs); - if (r.code !== 0) throw new Error(r.stderr || r.stdout || "gh issue create failed"); - return { cwd, ok: true, url: r.stdout.trim() }; + const result = await runGh(cwd, ghArgs); + return { + cwd, + code: result.code, + checks: result.stdout, + stderr: result.stderr || undefined, + ok: result.code === 0, + }; }, }, - ]); + ]; +} + +export const register: GodModePluginRegister = (api) => { + requireVersionedKernelClient(api.kernel); + const adapters = createGithubAdapters(); + api.objectTypes.register(githubRepositoryObjectType, adapters.repository); + api.objectTypes.register(pullRequestObjectType, adapters.pullRequest); + api.tools.register([...createReadTools(), ...createSemanticTools(api)]); }; diff --git a/src/gh-util.ts b/src/gh-util.ts index 9bc340b..3ae42af 100644 --- a/src/gh-util.ts +++ b/src/gh-util.ts @@ -5,73 +5,229 @@ import path from "node:path"; const MAX_OUT = 200_000; -export function resolveWorkingRoot( - ctx: { tenantId?: string }, - cwdArg?: unknown -): string { - if (typeof cwdArg === "string" && cwdArg.trim()) { - return path.resolve(cwdArg.trim()); +export interface GitHubContext { + tenantId?: string; + signal?: AbortSignal; +} + +export interface CommandResult { + code: number; + stdout: string; + stderr: string; +} + +export type CommandRunner = ( + cwd: string, + args: readonly string[], + opts?: { timeoutMs?: number; signal?: AbortSignal } +) => Promise; + +export class GitHubActionError extends Error { + readonly status: number; + readonly code: string; + readonly retryable: boolean; + readonly details?: unknown; + + constructor( + status: number, + code: string, + message: string, + options: { retryable?: boolean; details?: unknown } = {} + ) { + super(message); + this.name = "GitHubActionError"; + this.status = status; + this.code = code; + this.retryable = options.retryable ?? false; + this.details = options.details; } +} + +function dataDirectory(): string { + return ( + process.env.PLATFORM_DATA_DIR?.trim() || + path.join( + process.env.APPDATA || path.join(os.homedir(), ".local", "share"), + "GodMode" + ) + ); +} + +export function codingRoot(ctx: GitHubContext): string { const mode = (process.env.DEPLOYMENT_MODE ?? "local").toLowerCase(); - if ((mode === "hub" || mode === "client") && ctx.tenantId) { - const data = - process.env.PLATFORM_DATA_DIR?.trim() || - path.join( - process.env.APPDATA || path.join(os.homedir(), ".local", "share"), - "GodMode" + if (mode === "hub" || mode === "client") { + if (!ctx.tenantId) { + throw new GitHubActionError( + 403, + "GITHUB_TENANT_REQUIRED", + "Tenant context is required" ); - return path.join(data, "tenant-workspaces", ctx.tenantId); + } + if ( + !/^[A-Za-z0-9_-]+$/.test(ctx.tenantId) || + ctx.tenantId === "." || + ctx.tenantId === ".." + ) { + throw new GitHubActionError( + 400, + "GITHUB_INVALID_TENANT", + "Invalid tenant identifier" + ); + } + return path.resolve(dataDirectory(), "tenant-workspaces", ctx.tenantId); } - return process.env.PLATFORM_REPO_ROOT?.trim() || process.cwd(); + return path.resolve(process.env.PLATFORM_REPO_ROOT?.trim() || process.cwd()); } -function truncate(text: string): string { - const buf = Buffer.from(text, "utf8"); - if (buf.length <= MAX_OUT) return text; - return `${buf.subarray(0, MAX_OUT).toString("utf8")}\n…[truncated]`; +function canonicalExistingDirectory(value: string, label: string): string { + let canonical: string; + try { + canonical = fs.realpathSync.native(path.resolve(value)); + } catch { + throw new GitHubActionError( + 404, + "GITHUB_WORKING_ROOT_NOT_FOUND", + `${label} not found: ${value}` + ); + } + if (!fs.statSync(canonical).isDirectory()) { + throw new GitHubActionError( + 400, + "GITHUB_WORKING_ROOT_INVALID", + `${label} is not a directory: ${value}` + ); + } + return canonical; +} + +export function resolveWorkingRoot( + ctx: GitHubContext, + cwdArg?: unknown +): string { + const root = canonicalExistingDirectory(codingRoot(ctx), "Coding root"); + const requested = + typeof cwdArg === "string" && cwdArg.trim() + ? path.resolve(root, cwdArg.trim()) + : root; + const candidate = canonicalExistingDirectory(requested, "Working directory"); + const relative = path.relative(root, candidate); + if ( + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + throw new GitHubActionError( + 403, + "GITHUB_CODING_ROOT_ESCAPE", + "Working directory must remain inside the coding root" + ); + } + return candidate; +} + +function appendBounded(current: string, chunk: Buffer): string { + if (Buffer.byteLength(current, "utf8") >= MAX_OUT) return current; + const remaining = MAX_OUT - Buffer.byteLength(current, "utf8"); + const addition = chunk.subarray(0, remaining).toString("utf8"); + return `${current}${addition}${ + chunk.byteLength > remaining ? "\n…[truncated]" : "" + }`; } export async function runGh( cwd: string, - args: string[], - opts?: { timeoutMs?: number } -): Promise<{ code: number; stdout: string; stderr: string }> { - if (!fs.existsSync(cwd)) { - throw new Error(`Working directory not found: ${cwd}`); - } + args: readonly string[], + opts?: { timeoutMs?: number; signal?: AbortSignal } +): Promise { return new Promise((resolve, reject) => { const child = spawn("gh", args, { cwd, - shell: process.platform === "win32", - env: { ...process.env }, + shell: false, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + env: process.env, }); let stdout = ""; let stderr = ""; + let settled = false; + const finishError = (error: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + opts?.signal?.removeEventListener("abort", abort); + reject(error); + }; + const abort = () => { + child.kill(); + finishError( + new GitHubActionError( + 499, + "GITHUB_ACTION_CANCELLED", + "GitHub action was cancelled" + ) + ); + }; const timer = setTimeout(() => { - child.kill("SIGTERM"); - reject(new Error(`gh ${args[0]} timed out`)); + child.kill(); + finishError( + new GitHubActionError( + 504, + "GITHUB_ACTION_TIMEOUT", + `gh ${args[0] ?? "command"} timed out`, + { retryable: true } + ) + ); }, opts?.timeoutMs ?? 180_000); + timer.unref?.(); + if (opts?.signal?.aborted) { + abort(); + return; + } + opts?.signal?.addEventListener("abort", abort, { once: true }); child.stdout?.on("data", (c: Buffer) => { - stdout += c.toString(); + stdout = appendBounded(stdout, c); }); child.stderr?.on("data", (c: Buffer) => { - stderr += c.toString(); + stderr = appendBounded(stderr, c); }); child.on("error", (err) => { - clearTimeout(timer); - reject( - new Error( - `${err instanceof Error ? err.message : String(err)} — is gh on PATH?` + finishError( + new GitHubActionError( + 503, + "GITHUB_CLI_UNAVAILABLE", + `${err.message} — is gh on PATH?`, + { retryable: true } ) ); }); child.on("close", (code) => { + if (settled) return; + settled = true; clearTimeout(timer); + opts?.signal?.removeEventListener("abort", abort); resolve({ code: code ?? 1, - stdout: truncate(stdout), - stderr: truncate(stderr), + stdout, + stderr, }); }); }); } + +export function requireGhSuccess( + result: CommandResult, + fallback: string +): CommandResult { + if (result.code !== 0) { + throw new GitHubActionError( + 502, + "GITHUB_CLI_FAILED", + result.stderr.trim() || result.stdout.trim() || fallback, + { + retryable: true, + details: { exitCode: result.code }, + } + ); + } + return result; +} diff --git a/test/adapters.test.ts b/test/adapters.test.ts new file mode 100644 index 0000000..ace7cdf --- /dev/null +++ b/test/adapters.test.ts @@ -0,0 +1,172 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import type { PluginRecordContext } from "@godmode/plugin-api"; +import { + assertMergeReady, + createGithubAdapters, + pullRequestId, +} from "../src/adapters.js"; +import { + GitHubActionError, + resolveWorkingRoot, + type CommandRunner, +} from "../src/gh-util.js"; + +const originalEnv = { ...process.env }; + +function context(tenantId = "tenant-a"): PluginRecordContext { + return { + tenantId, + role: "intelligence", + source: "plugin", + activeAgentId: "agent-test", + }; +} + +test.afterEach(() => { + process.env = { ...originalEnv }; +}); + +test("coding roots reject traversal and absolute escapes", () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "github-root-")); + const root = path.join(base, "root"); + const child = path.join(root, "child"); + const outside = path.join(base, "outside"); + fs.mkdirSync(child, { recursive: true }); + fs.mkdirSync(outside); + process.env.DEPLOYMENT_MODE = "local"; + process.env.PLATFORM_REPO_ROOT = root; + + assert.equal(resolveWorkingRoot({}, "child"), fs.realpathSync.native(child)); + assert.throws( + () => resolveWorkingRoot({}, outside), + (error: unknown) => + error instanceof GitHubActionError && + error.code === "GITHUB_CODING_ROOT_ESCAPE" + ); +}); + +test("tenant coding roots cannot cross tenant boundaries", () => { + const data = fs.mkdtempSync(path.join(os.tmpdir(), "github-tenants-")); + const tenantA = path.join(data, "tenant-workspaces", "tenant-a"); + const tenantB = path.join(data, "tenant-workspaces", "tenant-b"); + fs.mkdirSync(tenantA, { recursive: true }); + fs.mkdirSync(tenantB, { recursive: true }); + process.env.DEPLOYMENT_MODE = "hub"; + process.env.PLATFORM_DATA_DIR = data; + + assert.equal(resolveWorkingRoot(context("tenant-a")), fs.realpathSync.native(tenantA)); + assert.throws( + () => resolveWorkingRoot(context("tenant-a"), tenantB), + (error: unknown) => + error instanceof GitHubActionError && + error.code === "GITHUB_CODING_ROOT_ESCAPE" + ); + assert.throws( + () => resolveWorkingRoot(context("../tenant-b")), + (error: unknown) => + error instanceof GitHubActionError && + error.code === "GITHUB_INVALID_TENANT" + ); +}); + +test("adapter passes hostile values as inert argv entries", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "github-injection-")); + process.env.DEPLOYMENT_MODE = "local"; + process.env.PLATFORM_REPO_ROOT = root; + const calls: Array<{ cwd: string; args: readonly string[] }> = []; + const runner: CommandRunner = async (cwd, args) => { + calls.push({ cwd, args }); + return { code: 0, stdout: "https://example.test/pr/1\n", stderr: "" }; + }; + const { repository } = createGithubAdapters(runner); + const hostile = `title"; rm -rf .; $(whoami)`; + + await repository.actions!.create_pull_request!( + root, + { title: hostile, body: "body && echo injected" }, + context() + ); + + assert.equal(calls.length, 1); + assert.deepEqual(calls[0]!.args, [ + "pr", + "create", + "--title", + hostile, + "--body", + "body && echo injected", + ]); +}); + +test("merge checks readiness before spawning merge", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "github-merge-")); + process.env.DEPLOYMENT_MODE = "local"; + process.env.PLATFORM_REPO_ROOT = root; + const calls: readonly string[][] = []; + const runner: CommandRunner = async (_cwd, args) => { + (calls as string[][]).push([...args]); + if (args[1] === "view") { + return { + code: 0, + stdout: JSON.stringify({ + state: "OPEN", + isDraft: false, + mergeable: "MERGEABLE", + statusCheckRollup: [{ conclusion: "SUCCESS" }], + }), + stderr: "", + }; + } + return { code: 0, stdout: "merged\n", stderr: "" }; + }; + const { pullRequest } = createGithubAdapters(runner); + + const result = await pullRequest.actions!.merge!( + pullRequestId(root, 42), + { method: "rebase", deleteBranch: false }, + context() + ); + + assert.deepEqual(calls[1], ["pr", "merge", "42", "--rebase"]); + assert.deepEqual(result, { + cwd: fs.realpathSync.native(root), + ok: true, + stdout: "merged\n", + readiness: { ready: true, checks: 1 }, + }); +}); + +test("merge readiness rejects drafts, conflicts, and pending checks", () => { + assert.throws( + () => + assertMergeReady({ + state: "OPEN", + isDraft: true, + mergeable: "MERGEABLE", + }), + /Draft pull requests/ + ); + assert.throws( + () => + assertMergeReady({ + state: "OPEN", + isDraft: false, + mergeable: "CONFLICTING", + }), + /not mergeable/ + ); + assert.throws( + () => + assertMergeReady({ + state: "OPEN", + isDraft: false, + mergeable: "MERGEABLE", + statusCheckRollup: [{ status: "IN_PROGRESS" }], + }), + /not successful/ + ); +}); diff --git a/test/contracts.test.ts b/test/contracts.test.ts new file mode 100644 index 0000000..9aa6e93 --- /dev/null +++ b/test/contracts.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { validateObjectTypeDef } from "@godmode/kernel"; +import { + githubRepositoryObjectType, + pullRequestObjectType, +} from "../src/adapters.js"; + +test("GitHub ObjectType contracts pass kernel validation", () => { + assert.deepEqual(validateObjectTypeDef(githubRepositoryObjectType), []); + assert.deepEqual(validateObjectTypeDef(pullRequestObjectType), []); +}); + +test("every mutation action declares a strict executable contract", () => { + const actions = [ + ...(githubRepositoryObjectType.actions ?? []), + ...(pullRequestObjectType.actions ?? []), + ]; + assert.equal(actions.length, 4); + for (const action of actions) { + assert.equal(action.target, "record"); + assert.ok(["external", "destructive"].includes(action.effect ?? "")); + assert.equal(action.execution, "sync"); + assert.ok(action.roles?.length); + assert.equal(action.confirmation?.required, true); + assert.equal(action.idempotency?.required, true); + assert.ok(action.timeoutMs && action.timeoutMs > 0); + assert.ok(action.inputSchema); + assert.ok(action.outputSchema); + assert.ok(action.errorSchema); + assert.ok(action.events?.length); + } +}); + +test("merge is destructive and restricted to privileged roles", () => { + const merge = pullRequestObjectType.actions?.find( + (action) => action.name === "merge" + ); + assert.equal(merge?.effect, "destructive"); + assert.deepEqual(merge?.roles, ["owner", "intelligence"]); + assert.equal(merge?.confirmation?.required, true); +}); diff --git a/test/manifest.test.ts b/test/manifest.test.ts new file mode 100644 index 0000000..c7d54ff --- /dev/null +++ b/test/manifest.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { + KERNEL_CLIENT_API_VERSION, + parseGodmodePluginManifest, +} from "@godmode/plugin-api"; + +test("manifest negotiates kernel client API version 1", () => { + const manifestPath = path.resolve("godmode.plugin.json"); + const manifest = parseGodmodePluginManifest( + JSON.parse(fs.readFileSync(manifestPath, "utf8")) + ); + + assert.equal(manifest.kernelApiVersion, KERNEL_CLIENT_API_VERSION); + assert.equal(manifest.kernelApiVersion, 1); +}); + +test("manifest negotiation rejects a mismatched kernel API version", () => { + assert.throws( + () => + parseGodmodePluginManifest({ + id: "godmode-plugin-github", + version: "0.1.0", + name: "GitHub", + kernelApiVersion: KERNEL_CLIENT_API_VERSION + 1, + }), + /unsupported kernelApiVersion 2; host supports 1/ + ); +}); diff --git a/test/semantic-tools.test.ts b/test/semantic-tools.test.ts new file mode 100644 index 0000000..5127772 --- /dev/null +++ b/test/semantic-tools.test.ts @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { + KERNEL_CLIENT_API_VERSION, + type GodModePluginApi, + type PluginRecordContext, +} from "@godmode/plugin-api"; +import { + createSemanticTools, + register, + requireVersionedKernelClient, +} from "../src/bridge.js"; + +test("mutation tools delegate exclusively to kernel actions", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "github-semantic-")); + const oldMode = process.env.DEPLOYMENT_MODE; + const oldRoot = process.env.PLATFORM_REPO_ROOT; + process.env.DEPLOYMENT_MODE = "local"; + process.env.PLATFORM_REPO_ROOT = root; + const calls: Array<{ + objectType: string; + action: string; + input: Record; + ctx: PluginRecordContext; + id?: string; + }> = []; + const api = { + kernel: { + apiVersion: KERNEL_CLIENT_API_VERSION, + async runAction( + objectType: string, + action: string, + input: Record, + ctx: PluginRecordContext, + id?: string + ) { + calls.push({ objectType, action, input, ctx, id }); + return { ok: true }; + }, + }, + } as unknown as GodModePluginApi; + const tools = createSemanticTools(api); + const executionContext = { + tenantId: "tenant-a", + activeAgentId: "intelligence", + requestId: "tool-call-1", + }; + + await tools.find((tool) => tool.name === "gh_pr_create")!.handler!( + { title: "Title", body: "Body" }, + executionContext as never + ); + await tools.find((tool) => tool.name === "gh_pr_comment")!.handler!( + { number: 7, body: "Comment" }, + executionContext as never + ); + await tools.find((tool) => tool.name === "gh_pr_merge")!.handler!( + { number: 7 }, + executionContext as never + ); + await tools.find((tool) => tool.name === "gh_issue_create")!.handler!( + { title: "Issue" }, + executionContext as never + ); + + assert.deepEqual( + calls.map(({ objectType, action }) => [objectType, action]), + [ + ["GitHubRepository", "create_pull_request"], + ["PullRequest", "comment"], + ["PullRequest", "merge"], + ["GitHubRepository", "create_issue"], + ] + ); + assert.ok(calls.every((call) => call.ctx.idempotencyKey === "tool-call-1")); + assert.ok(calls.every((call) => call.id)); + + process.env.DEPLOYMENT_MODE = oldMode; + process.env.PLATFORM_REPO_ROOT = oldRoot; +}); + +test("confirmed wrappers consume and replay kernel confirmation grants", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "github-confirm-")); + process.env.DEPLOYMENT_MODE = "local"; + process.env.PLATFORM_REPO_ROOT = root; + const contexts: PluginRecordContext[] = []; + const api = { + kernel: { + apiVersion: KERNEL_CLIENT_API_VERSION, + async runAction( + _objectType: string, + _action: string, + _input: Record, + ctx: PluginRecordContext + ) { + contexts.push(ctx); + if (contexts.length === 1) { + throw Object.assign(new Error("confirmation required"), { + status: 428, + code: "KERNEL_CONFIRMATION_REQUIRED", + details: { confirmationId: "grant-123" }, + }); + } + return { ok: true }; + }, + }, + } as unknown as GodModePluginApi; + const tool = createSemanticTools(api).find( + (candidate) => candidate.name === "gh_issue_create" + )!; + + const result = await tool.handler!( + { title: "Confirmed", idempotencyKey: "same-operation" }, + { + tenantId: "tenant-a", + activeAgentId: "intelligence", + } as never + ); + + assert.deepEqual(result, { ok: true }); + assert.equal(contexts.length, 2); + assert.equal(contexts[0]!.idempotencyKey, "same-operation"); + assert.equal(contexts[1]!.idempotencyKey, "same-operation"); + assert.equal(contexts[1]!.confirmationId, "grant-123"); +}); + +test("versioned client contract rejects missing and mismatched hosts clearly", () => { + assert.throws( + () => + requireVersionedKernelClient({ + apiVersion: 2, + } as never), + /requires kernel client API version 1; host provided 2/ + ); + assert.throws( + () => + register({ + kernel: {}, + } as unknown as GodModePluginApi), + /requires kernel client API version 1; host provided no version/ + ); +}); diff --git a/tsconfig.json b/tsconfig.json index c0e6291..e64e038 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,8 @@ "rootDir": "src", "strict": true, "skipLibCheck": true, - "noEmit": true + "declaration": true, + "types": ["node"] }, "include": ["src"] } diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..45bbe8f --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src", "test"] +} From 55c3320ff3eecd955b5794c3ce2df023cd12fd17 Mon Sep 17 00:00:00 2001 From: ReBoticsAI Date: Tue, 14 Jul 2026 21:40:53 -0600 Subject: [PATCH 2/3] Document ObjectType kernel tool behavior. Clarify which GitHub tools use kernel actions so operators and agents follow the migrated confirmation, idempotency, and merge-readiness contracts. --- README.md | 41 ++++++++++++++++------ data/ai/skills/github-pr-workflow/SKILL.md | 18 ++++++---- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index a39eb16..2cf133a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # GodMode GitHub plugin -Official marketplace plugin: **GitHub** pull request and CI tools for Intelligence via the [`gh`](https://cli.github.com/) CLI. +Official marketplace plugin: **GitHub** pull request and CI tools for Intelligence. Read tools call the [`gh`](https://cli.github.com/) CLI directly; mutations execute through versioned ObjectType kernel actions backed by `gh` adapters. ## Requirements @@ -8,24 +8,43 @@ Official marketplace plugin: **GitHub** pull request and CI tools for Intelligen - Auth: `gh auth login` **or** `GITHUB_TOKEN` / `GH_TOKEN` in the environment (do not commit tokens) - Usually used with **godmode-plugin-git** for local commit/push +## Architecture + +The manifest negotiates kernel client API version 1. At registration, the plugin installs two adapter-backed ObjectTypes: + +- `GitHubRepository`: `create_pull_request` and `create_issue` +- `PullRequest`: `comment` and `merge` + +The four mutation tools call `api.kernel.runAction(...)`; they do not invoke `gh` directly. Their action contracts require confirmation and idempotency, declare timeouts and structured errors, and emit kernel events. If the kernel returns a confirmation grant, the tool wrapper replays the same action once with that grant and the same idempotency key. + +Repository paths are resolved inside the active coding root. In hub/client deployments, that root is tenant-scoped. Absolute or traversal escapes are rejected. + ## Tools -| Tool | Mode | Purpose | -|------|------|---------| -| `gh_pr_list` | auto | List PRs for the current repo | -| `gh_pr_view` | auto | View one PR (JSON) | -| `gh_pr_checks` | auto | CI check rollup for a PR | -| `gh_pr_create` | confirm | Create a PR (title + body) | -| `gh_pr_comment` | confirm | Comment on a PR | -| `gh_pr_merge` | confirm | Merge when appropriate (default squash) | -| `gh_issue_create` | confirm | Open a GitHub issue | +| Tool | Mode | Execution and result | +|------|------|----------------------| +| `gh_pr_list` | auto | Direct read: lists PRs with `gh pr list`; defaults to open, limit 20 | +| `gh_pr_view` | auto | Direct read: returns PR JSON from `gh pr view` | +| `gh_pr_checks` | auto | Direct read: returns `ok`, exit `code`, text `checks`, and optional `stderr`; the PR number is optional | +| `gh_pr_create` | confirm | `GitHubRepository.create_pull_request`; requires title and body | +| `gh_pr_comment` | confirm | `PullRequest.comment`; requires PR number and body | +| `gh_pr_merge` | confirm | `PullRequest.merge`; verifies open/non-draft/mergeable status and successful checks before merging | +| `gh_issue_create` | confirm | `GitHubRepository.create_issue`; requires a title | + +All tools accept an optional `cwd` relative to the coding root. Mutation tools also accept an optional stable `idempotencyKey`; otherwise the active tool request ID is used. + +`gh_pr_merge` defaults to squash and branch deletion. Set `method` to `merge` or `rebase`, or set `deleteBranch: false`, to override those defaults. ## Safety - Does not store PATs in the plugin. -- Prefer waiting for green `gh_pr_checks` before `gh_pr_merge`. +- Use `gh_pr_checks` to inspect CI before requesting a merge. The merge action independently rejects closed or draft PRs, conflicts, unknown mergeability, and checks not reported as `SUCCESS`, `NEUTRAL`, or `SKIPPED`. - Never put secrets, ops passwords, or private family content in PR bodies. +## Verification + +Run `npm run validate` to type-check source and tests, execute the ObjectType/adapter/tool contract tests, and build the bridge. The suite verifies kernel API negotiation, strict action contracts, mutation delegation, confirmation replay, coding-root isolation, safe argv handling, and merge-readiness enforcement. + ## Install Marketplace → Official → **GitHub**, or Unofficial with this repo URL. diff --git a/data/ai/skills/github-pr-workflow/SKILL.md b/data/ai/skills/github-pr-workflow/SKILL.md index c775e5d..7b1b6c6 100644 --- a/data/ai/skills/github-pr-workflow/SKILL.md +++ b/data/ai/skills/github-pr-workflow/SKILL.md @@ -1,11 +1,11 @@ --- name: github-pr-workflow -description: Open and land GitHub PRs with gh plugin tools (create, checks, merge) +description: Open and land GitHub PRs with kernel-backed mutations and gh read tools tools: ["gh_pr_list", "gh_pr_view", "gh_pr_checks", "gh_pr_create", "gh_pr_comment", "gh_pr_merge", "gh_issue_create", "git_status", "git_push"] --- 1. Ensure the branch is pushed (`git_push` from godmode-plugin-git). -2. `gh_pr_create` with: +2. Call `gh_pr_create` with: - Title: concise why - Body: ``` @@ -15,7 +15,13 @@ tools: ["gh_pr_list", "gh_pr_view", "gh_pr_checks", "gh_pr_create", "gh_pr_comme ## Test plan - [ ] … ``` -3. Share the returned PR URL with the user. -4. Poll `gh_pr_checks` until green (or report failures). Avoid tight busy-loops — wait between checks. -5. Only when the user asks to land it: `gh_pr_merge` (squash by default). -6. Keep secrets and private ops out of the PR. + - Optional `base`, `draft`, and stable `idempotencyKey` + - Optional `cwd` relative to the active coding root +3. Share the returned PR URL with the user. `gh_pr_create` delegates to the `GitHubRepository.create_pull_request` kernel action. +4. Use `gh_pr_checks` for a CI snapshot. It returns `ok`, exit `code`, text `checks`, and optional `stderr`; rerun only after waiting when checks are pending. +5. Only when the user asks to land it, call `gh_pr_merge`. It delegates to `PullRequest.merge`, which independently requires an open, non-draft, mergeable PR whose checks are `SUCCESS`, `NEUTRAL`, or `SKIPPED`. + - Merge defaults: squash and delete the branch + - Override with `method: "merge" | "rebase"` or `deleteBranch: false` +6. Treat `gh_pr_create`, `gh_pr_comment`, `gh_pr_merge`, and `gh_issue_create` as confirmed kernel mutations. Their wrappers preserve the idempotency key and replay once when the kernel supplies a confirmation grant. +7. `gh_pr_list`, `gh_pr_view`, and `gh_pr_checks` are read-only direct `gh` calls; they do not run ObjectType actions. +8. Keep secrets and private ops out of PRs, comments, and issues. From 4be058150158f86cf9a2861c6fd0c3d5b890a9e8 Mon Sep 17 00:00:00 2001 From: ReBoticsAI Date: Tue, 14 Jul 2026 21:49:11 -0600 Subject: [PATCH 3/3] Pin compatible GodMode kernel API. Keep plugin validation reproducible after the migration branch is merged or removed. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af94f9b..10ce063 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: uses: actions/checkout@v4 with: repository: ReBoticsAI/GodMode - ref: feat/objecttype-kernel-migration + ref: 732d993808d641645ed706d8560719cba5349d6f path: GodMode - name: Set up Node uses: actions/setup-node@v4