Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 76 additions & 3 deletions github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ This will walk you through installing the GitHub app, creating the workflow, and

### Manual Setup

1. Install the GitHub app https://github.com/apps/opencode-agent. Make sure it is installed on the target repository.
1. Install the GitHub app <https://github.com/apps/opencode-agent>. Make sure it is installed on the target repository.
2. Add the following workflow file to `.github/workflows/opencode.yml` in your repo. Set the appropriate `model` and required API keys in `env`.

```yml
Expand Down Expand Up @@ -76,9 +76,82 @@ This will walk you through installing the GitHub app, creating the workflow, and

3. Store the API keys in secrets. In your organization or project **settings**, expand **Secrets and variables** on the left and select **Actions**. Add the required API keys.

## Custom Provider Configuration

You can configure custom providers using an `opencode.json` config file in your repository root:

### Basic Usage with Config File

1. Create `.github/opencode.json` in your repository:

```json
{
"model": "custom-provider/my-model",
"provider": {
"custom-provider": {
"npm": "@ai-sdk/openai-compatible",
"models": {
"my-model": {
"name": "My Custom Model"
}
},
"options": {
"apiKey": "{env:CUSTOM_API_KEY}",
"baseURL": "{env:CUSTOM_BASE_URL}"
}
}
}
}
```

2. Update your workflow to use the config:

```yml
- name: Run opencode
uses: sst/opencode/github@latest
env:
CUSTOM_API_KEY: ${{ secrets.CUSTOM_API_KEY }}
CUSTOM_BASE_URL: ${{ secrets.CUSTOM_BASE_URL }}
# No model parameter needed - will automatically use .github/opencode.json
```

### Advanced Configuration

You can also specify a custom config file path and pass environment variables:

```yml
- name: Run opencode
uses: sst/opencode/github@latest
with:
config: opencode-ci.json
config_env: |
CUSTOM_API_KEY=${{ secrets.CUSTOM_API_KEY }}
CUSTOM_BASE_URL=https://api.example.com
DEBUG=true
# model parameter will override config if specified
```

### Action Inputs

| Input | Description | Required | Default |
| ------------ | ------------------------------------------------------------- | -------- | ----------------------- |
| `model` | Model to use (overrides config file) | No | - |
| `config` | Path to opencode config file | No | Auto-discovery |
| `config_env` | Environment variables for config (multiline key=value format) | No | - |
| `share` | Share the opencode session | No | `true` for public repos |

### Config File Discovery

The action will automatically look for config files in this order:

1. Path specified in `config` input
2. `.github/opencode.json`
3. `opencode.json` in repository root
4. `.opencode/config.json` in repository root

## Support

This is an early release. If you encounter issues or have feedback, please create an issue at https://github.com/sst/opencode/issues.
This is an early release. If you encounter issues or have feedback, please create an issue at <https://github.com/sst/opencode/issues>.

## Development

Expand Down Expand Up @@ -122,7 +195,7 @@ Replace:
- `"number":4` with the GitHub issue id
- `"body":"hey opencode, summarize thread"` with comment body

### Issue comment with image attachment.
### Issue comment with image attachment

```
--event '{"eventName":"issue_comment","repo":{"owner":"sst","repo":"hello-world"},"actor":"fwang","payload":{"issue":{"number":4},"comment":{"id":1,"body":"hey opencode, what is in my image ![Image](https://github.com/user-attachments/assets/xxxxxxxx)"}}}'
Expand Down
14 changes: 12 additions & 2 deletions github/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,16 @@ branding:

inputs:
model:
description: "Model to use"
required: true
description: "Model to use (overrides config file)"
required: false

opencode_config:
description: "Path to opencode config file (defaults to .github/opencode.json)"
required: false

config_env:
description: "Environment variables for config (multiline key=value format)"
required: false

share:
description: "Share the opencode session (defaults to true for public repos)"
Expand All @@ -26,4 +34,6 @@ runs:
run: opencode github run
env:
MODEL: ${{ inputs.model }}
OPENCODE_CONFIG: ${{ inputs.config }}
CONFIG_ENV: ${{ inputs.config_env }}
SHARE: ${{ inputs.share }}
114 changes: 99 additions & 15 deletions packages/opencode/src/cli/cmd/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { Identifier } from "../../id/id"
import { Provider } from "../../provider/provider"
import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2"
import { Config } from "../../config/config"

type GitHubAuthor = {
login: string
Expand Down Expand Up @@ -129,7 +130,7 @@ export const GithubCommand = cmd({
command: "github",
describe: "manage GitHub agent",
builder: (yargs) => yargs.command(GithubInstallCommand).command(GithubRunCommand).demandCommand(),
async handler() {},
async handler() { },
})

export const GithubInstallCommand = cmd({
Expand Down Expand Up @@ -364,7 +365,8 @@ export const GithubRunCommand = cmd({
process.exit(1)
}

const { providerID, modelID } = normalizeModel()
await loadConfiguration()
const { providerID, modelID } = await normalizeModel()
const runId = normalizeRunId()
const share = normalizeShare()
const { owner, repo } = context.repo
Expand Down Expand Up @@ -481,9 +483,91 @@ export const GithubRunCommand = cmd({
}
process.exit(exitCode)

function normalizeModel() {
function parseConfigEnvironment(): Record<string, string> {
const configEnv = process.env["CONFIG_ENV"]
if (!configEnv) return {}

try {
// Parse key=value format only
const result: Record<string, string> = {}
configEnv.split("\n").forEach((line) => {
const trimmed = line.trim()
if (trimmed && !trimmed.startsWith("#")) {
const [key, ...valueParts] = trimmed.split("=")
if (key && valueParts.length > 0) {
result[key.trim()] = valueParts.join("=").trim()
}
}
})

return result
} catch (error) {
console.warn("Failed to parse CONFIG_ENV, ignoring:", error)
return {}
}
}

async function loadConfiguration() {
// Parse and set additional environment variables
const additionalEnv = parseConfigEnvironment()
for (const [key, value] of Object.entries(additionalEnv)) {
process.env[key] = value
console.log(`Set environment variable: ${key}`)
}

// Load config file if specified or discover default
const configPath = process.env["OPENCODE_CONFIG"]
if (configPath) {
console.log(`Using config from OPENCODE_CONFIG: ${configPath}`)
try {
// Check if config file exists
const fs = await import("fs")
const path = await import("path")
const fullPath = path.resolve(configPath)
if (!fs.existsSync(fullPath)) {
throw new Error(`Config file not found: ${fullPath}`)
}
console.log(`Config file found: ${fullPath}`)
} catch (error) {
console.warn(`Config file error: ${error instanceof Error ? error.message : String(error)}`)
}
} else {
// Auto-discover config files and set OPENCODE_CONFIG
const fs = await import("fs")
const path = await import("path")
const defaultConfigs = [".github/opencode.json", "opencode.json", ".opencode/config.json"]

for (const defaultConfig of defaultConfigs) {
if (fs.existsSync(defaultConfig)) {
const fullPath = path.resolve(defaultConfig)
console.log(`Found default config: ${defaultConfig}`)
console.log(`Setting OPENCODE_CONFIG to: ${fullPath}`)
process.env["OPENCODE_CONFIG"] = fullPath
break
}
}
}
}

async function normalizeModel() {
const value = process.env["MODEL"]
if (!value) throw new Error(`Environment variable "MODEL" is not set`)

// If MODEL is not set, try to get default from config
if (!value) {
try {
const cfg = await Config.get()
if (cfg.model) {
console.log(`Using default model from config: ${cfg.model}`)
const { providerID, modelID } = Provider.parseModel(cfg.model)
if (!providerID.length || !modelID.length)
throw new Error(`Invalid model ${cfg.model}. Model must be in the format "provider/model".`)
return { providerID, modelID }
}
} catch (error) {
console.warn(`Failed to load config: ${error instanceof Error ? error.message : String(error)}`)
}
throw new Error(`Environment variable "MODEL" is not set and no default model found in config`)
}

const { providerID, modelID } = Provider.parseModel(value)

Expand Down Expand Up @@ -694,18 +778,18 @@ export const GithubRunCommand = cmd({
async function exchangeForAppToken(token: string) {
const response = token.startsWith("github_pat_")
? await fetch("https://api.opencode.ai/exchange_github_app_token_with_pat", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ owner, repo }),
})
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ owner, repo }),
})
: await fetch("https://api.opencode.ai/exchange_github_app_token", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
})
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
})

if (!response.ok) {
const responseJson = (await response.json()) as { error?: string }
Expand Down