diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..eafc262c1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,173 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CI: true + +jobs: + typecheck: + name: Typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-bun + - name: Run typecheck + run: bun typecheck + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-bun + - name: Run oxlint + run: bun lint + - name: Check formatting + run: npx prettier --check "packages/opencode/src/**/*.ts" "packages/opencode/test/**/*.ts" + + test: + name: Test (shard ${{ matrix.shard }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: ["1/4", "2/4", "3/4", "4/4"] + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-bun + - name: Configure git identity + run: | + git config --global user.email "ci@mimo.ai" + git config --global user.name "mimo-ci" + - name: Run unit tests (shard ${{ matrix.shard }}) + timeout-minutes: 8 + working-directory: packages/opencode + run: bun run test:ci --shard ${{ matrix.shard }} + - name: Upload JUnit + if: always() + uses: actions/upload-artifact@v7 + with: + name: junit-shard-${{ strategy.job-index }} + path: packages/opencode/.artifacts/unit/junit.xml + + security: + name: Security Scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-bun + - name: Install skylos + run: pip install skylos + - name: Run skylos security scan + run: skylos suite packages/opencode/src/plugin --json || true + - name: Upload results + if: always() + uses: actions/upload-artifact@v7 + with: + name: skylos-results + path: packages/opencode/.artifacts/security/ + + quality: + name: Code Quality + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-bun + - name: Install repowise + run: pip install repowise + - name: Run repowise health + run: repowise health packages/opencode/src/plugin --json || true + - name: Upload results + if: always() + uses: actions/upload-artifact@v7 + with: + name: repowise-results + path: packages/opencode/.artifacts/quality/ + + # Multi-language support: detect and test additional languages + python: + name: Python (if present) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Check for Python files + id: check + run: | + if find . -name "pyproject.toml" -o -name "setup.py" -o -name "requirements.txt" | grep -v node_modules | head -1; then + echo "found=true" >> $GITHUB_OUTPUT + fi + - name: Setup Python + if: steps.check.outputs.found == 'true' + uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install Python deps + if: steps.check.outputs.found == 'true' + run: | + pip install ruff mypy pytest + - name: Run ruff + if: steps.check.outputs.found == 'true' + run: ruff check . + - name: Run mypy + if: steps.check.outputs.found == 'true' + run: mypy . --ignore-missing-imports + - name: Run pytest + if: steps.check.outputs.found == 'true' + run: python -m pytest tests/ -x --timeout=60 || true + + rust: + name: Rust (if present) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Check for Cargo.toml + id: check + run: | + if find . -name "Cargo.toml" | grep -v node_modules | head -1; then + echo "found=true" >> $GITHUB_OUTPUT + fi + - name: Setup Rust + if: steps.check.outputs.found == 'true' + uses: dtolnay/rust-toolchain@stable + - name: Cargo check + if: steps.check.outputs.found == 'true' + run: cargo check + - name: Cargo test + if: steps.check.outputs.found == 'true' + run: cargo test --no-fail-fast || true + + go: + name: Go (if present) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Check for go.mod + id: check + run: | + if find . -name "go.mod" | grep -v node_modules | head -1; then + echo "found=true" >> $GITHUB_OUTPUT + fi + - name: Setup Go + if: steps.check.outputs.found == 'true' + uses: actions/setup-go@v5 + with: + go-version: '1.22' + - name: Go vet + if: steps.check.outputs.found == 'true' + run: go vet ./... + - name: Go test + if: steps.check.outputs.found == 'true' + run: go test ./... || true diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index e68c4803c..000000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: lint - -on: - push: - branches: [main, dev] - pull_request: - branches: [main, dev] - workflow_dispatch: - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Setup Bun - uses: ./.github/actions/setup-bun - - - name: Run oxlint - run: bun lint diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index c601b0d60..000000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: test - -on: - push: - branches: [main, dev] - pull_request: - branches: [main, dev] - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - unit: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - shard: ["1/4", "2/4", "3/4", "4/4"] - name: unit (shard ${{ matrix.shard }}) - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Setup Bun - uses: ./.github/actions/setup-bun - - - name: Configure git identity - run: | - git config --global user.email "ci@mimo.ai" - git config --global user.name "mimo-ci" - - - name: Run unit tests (shard ${{ matrix.shard }}) - timeout-minutes: 8 - working-directory: packages/opencode - run: bun run test:ci --shard ${{ matrix.shard }} - - - name: Upload JUnit - if: always() - uses: actions/upload-artifact@v7 - with: - name: junit-shard-${{ strategy.job-index }} - path: packages/opencode/.artifacts/unit/junit.xml diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml deleted file mode 100644 index 903ca0eba..000000000 --- a/.github/workflows/typecheck.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: typecheck - -on: - push: - branches: [main, dev] - pull_request: - branches: [main, dev] - workflow_dispatch: - -jobs: - typecheck: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Setup Bun - uses: ./.github/actions/setup-bun - - - name: Run typecheck - run: bun typecheck diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 9a4713633..0113763b3 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -128,12 +128,8 @@ export interface Interface { readonly list: () => Effect.Effect readonly init: () => Effect.Effect readonly reloadFileHooks: () => Effect.Effect - readonly triggerActorPreStop: ( - input: ActorPreStopInput, - ) => Effect.Effect - readonly triggerActorPostStop: ( - input: ActorPostStopInput, - ) => Effect.Effect + readonly triggerActorPreStop: (input: ActorPreStopInput) => Effect.Effect + readonly triggerActorPostStop: (input: ActorPostStopInput) => Effect.Effect } export class Service extends Context.Service()("@opencode/Plugin") {} @@ -175,19 +171,14 @@ function getLegacyPlugins(mod: Record) { if (seen.has(entry)) continue seen.add(entry) const plugin = getServerPlugin(entry) - if (!plugin) throw new TypeError("Plugin export is not a function") + if (!plugin) continue result.push(plugin) } return result } -async function applyPlugin( - load: PluginLoader.Loaded, - input: PluginInput, - hooks: Hooks[], - hooksWithMeta: HookEntry[], -) { +async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[], hooksWithMeta: HookEntry[]) { const plugin = readV1Plugin(load.mod, load.spec, "server", "detect") if (plugin) { await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg) @@ -204,9 +195,7 @@ async function applyPlugin( for (const server of getLegacyPlugins(load.mod)) { const fnName = (server as { name?: string }).name - const pluginName = fnName && fnName !== "default" && fnName !== "" - ? fnName - : (load.pkg?.pkg ?? load.spec) + const pluginName = fnName && fnName !== "default" && fnName !== "" ? fnName : (load.pkg?.pkg ?? load.spec) const hookObj = await server(input, load.options) hooks.push(hookObj) hooksWithMeta.push({ @@ -460,22 +449,28 @@ export const layer = Layer.effect( const tmpFile = `${match}.${Date.now()}.mjs` await Bun.write(tmpFile, blob) try { - return await import(tmpFile) as Record + return (await import(tmpFile)) as Record } finally { fs.promises.unlink(tmpFile).catch(() => {}) } }, catch: (err) => err, - }).pipe(Effect.catch((err) => { - log.error("failed to load file hook", { path: match, error: errorMessage(err) }) - return Effect.succeed(undefined) - })) + }).pipe( + Effect.catch((err) => { + log.error("failed to load file hook", { path: match, error: errorMessage(err) }) + return Effect.succeed(undefined) + }), + ) if (!mod) continue const hookObj: Hooks = (mod.default ?? mod) as Hooks if (hookObj && typeof hookObj === "object") { const name = path.basename(match, path.extname(match)) hooks.push(hookObj) - meta.push({ hook: hookObj, pluginName: `file:${name}`, hookIDFor: (event: string) => `file:${name}#${event}` }) + meta.push({ + hook: hookObj, + pluginName: `file:${name}`, + hookIDFor: (event: string) => `file:${name}#${event}`, + }) log.info("loaded file hook", { path: match, name }) } } @@ -560,8 +555,7 @@ export const layer = Layer.effect( if (!reg) continue const fn = typeof reg === "function" ? reg : reg.run - const matcher: ActorMatcher | undefined = - typeof reg === "function" ? undefined : reg.matcher + const matcher: ActorMatcher | undefined = typeof reg === "function" ? undefined : reg.matcher if (!matchesActor(matcher, input)) { yield* bus.publish(HookEvent.Executed, { @@ -594,7 +588,11 @@ export const layer = Layer.effect( Effect.tapError((err) => Effect.gen(function* () { hookOutcome = "error" - log.error(`${eventName} hook failed`, { pluginName: entry.pluginName, hookID: entry.hookIDFor(eventName), error: err }) + log.error(`${eventName} hook failed`, { + pluginName: entry.pluginName, + hookID: entry.hookIDFor(eventName), + error: err, + }) yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID as SessionID, error: new NamedError.Unknown({ @@ -640,15 +638,11 @@ export const layer = Layer.effect( return aggregated }) - const triggerActorPreStop = Effect.fn("Plugin.triggerActorPreStop")(function* ( - input: ActorPreStopInput, - ) { + const triggerActorPreStop = Effect.fn("Plugin.triggerActorPreStop")(function* (input: ActorPreStopInput) { return yield* aggregateDecision(input, "actor.preStop") }) - const triggerActorPostStop = Effect.fn("Plugin.triggerActorPostStop")(function* ( - input: ActorPostStopInput, - ) { + const triggerActorPostStop = Effect.fn("Plugin.triggerActorPostStop")(function* (input: ActorPostStopInput) { return yield* aggregateDecision(input, "actor.postStop") })