From f87d70a6cb4f9c3001950eedeffff49fb317216d Mon Sep 17 00:00:00 2001 From: Fernando Pinto Date: Sun, 3 May 2026 11:37:19 +0100 Subject: [PATCH 01/15] wip --- .github/workflows/opencode-issue-plan.yml | 49 +++++++++++++++ .github/workflows/opencode.yml | 33 ++++++++++ .opencode/agents/issue-planner.md | 73 +++++++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 .github/workflows/opencode-issue-plan.yml create mode 100644 .github/workflows/opencode.yml create mode 100644 .opencode/agents/issue-planner.md diff --git a/.github/workflows/opencode-issue-plan.yml b/.github/workflows/opencode-issue-plan.yml new file mode 100644 index 00000000..6c24f5a3 --- /dev/null +++ b/.github/workflows/opencode-issue-plan.yml @@ -0,0 +1,49 @@ +# .github/workflows/opencode-issue-plan.yml +name: OpenCode Issue Plan + +on: + issues: + types: [labeled] + +permissions: + id-token: write + contents: read + issues: write + +concurrency: + group: opencode-plan-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + plan: + if: github.event.label.name == 'opencode:plan' + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Create implementation plan as issue comment + uses: anomalyco/opencode/github@latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + with: + use_github_token: true + agent: issue-planner + model: opencode/big-pickle + prompt: | + Create an implementation plan for issue #${{ github.event.issue.number }}. + + Store the plan as a GitHub issue comment. + The comment must include exactly one block wrapped with: + + + + + Do not modify files. + Do not create a branch. + Do not open a pull request. \ No newline at end of file diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml new file mode 100644 index 00000000..07d33d76 --- /dev/null +++ b/.github/workflows/opencode.yml @@ -0,0 +1,33 @@ +name: opencode + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + +jobs: + opencode: + if: | + contains(github.event.comment.body, ' /oc') || + startsWith(github.event.comment.body, '/oc') || + contains(github.event.comment.body, ' /opencode') || + startsWith(github.event.comment.body, '/opencode') + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + pull-requests: read + issues: read + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Run opencode + uses: anomalyco/opencode/github@latest + env: + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + with: + model: opencode/big-pickle \ No newline at end of file diff --git a/.opencode/agents/issue-planner.md b/.opencode/agents/issue-planner.md new file mode 100644 index 00000000..5def503b --- /dev/null +++ b/.opencode/agents/issue-planner.md @@ -0,0 +1,73 @@ +# .opencode/agents/issue-planner.md +--- +description: Creates an implementation plan for a GitHub issue without modifying files. +mode: primary +temperature: 0.2 +permission: + read: allow + list: allow + glob: allow + grep: allow + lsp: allow + edit: deny + external_directory: deny + todowrite: allow + webfetch: deny + websearch: deny + bash: + "*": deny + "git status*": allow + "git log*": allow + "git show*": allow + "gh issue view*": allow + "gh issue comment*": allow +--- + +You are an issue planning agent. + +Your job is to read a GitHub issue, inspect the repository, and create a clear implementation plan. + +You must not: +- Edit files +- Apply patches +- Commit +- Push +- Create branches +- Open pull requests + +You must: +1. Read the issue. +2. Inspect relevant files. +3. Identify the likely implementation approach. +4. Identify risks and edge cases. +5. Identify tests that should be added or updated. +6. Comment the plan on the issue. + +The issue comment must use this format: + +## OpenCode Plan + +### Problem +Summarize the issue. + +### Relevant files +List likely files or modules to change. + +### Proposed approach +Describe the implementation steps. + +### Tests +List tests to add or update. + +### Risks +List compatibility, security, migration, performance, or operational risks. + +### Questions +List questions only if they block implementation. + +### Ready for implementation +Choose one: +- Yes +- No + +If the issue is ambiguous, unsafe, or too broad, mark Ready for implementation as No. \ No newline at end of file From c5536d4f496e224b5dea141e66fd38de3af3562c Mon Sep 17 00:00:00 2001 From: Fernando Pinto Date: Sun, 3 May 2026 11:47:32 +0100 Subject: [PATCH 02/15] wip --- .../workflows/opencode-issue-implement.yml | 64 +++++++++++ .github/workflows/opencode-pr-review.yml | 43 +++++++ .opencode/agents/issue-implementer.md | 105 ++++++++++++++++++ .opencode/agents/pr-review.md | 60 ++++++++++ 4 files changed, 272 insertions(+) create mode 100644 .github/workflows/opencode-issue-implement.yml create mode 100644 .github/workflows/opencode-pr-review.yml create mode 100644 .opencode/agents/issue-implementer.md create mode 100644 .opencode/agents/pr-review.md diff --git a/.github/workflows/opencode-issue-implement.yml b/.github/workflows/opencode-issue-implement.yml new file mode 100644 index 00000000..6c9d80ee --- /dev/null +++ b/.github/workflows/opencode-issue-implement.yml @@ -0,0 +1,64 @@ +# .github/workflows/opencode-issue-implement.yml +name: OpenCode Issue Implement + +on: + issues: + types: [labeled] + +permissions: + id-token: write + contents: write + issues: write + pull-requests: write + +concurrency: + group: opencode-implement-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + implement: + if: github.event.label.name == 'opencode:implement' + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Configure Git author + run: | + git config user.name "opencode[bot]" + git config user.email "opencode[bot]@users.noreply.github.com" + + - name: Implement issue from latest OpenCode Plan comment + uses: anomalyco/opencode/github@latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + with: + use_github_token: true + agent: issue-implementer + model: opencode/big-pickle + prompt: | + Implement issue #${{ github.event.issue.number }}. + + Before editing files: + 1. Read the issue and comments. + 2. Find the latest issue comment containing: + + + + 3. Extract the plan between: + + + + + 4. If no plan exists, do not modify files. + 5. If the plan says "Ready for implementation: No", do not modify files. + 6. If ready, implement the smallest correct change, add or update tests, run relevant checks, commit, push a branch, and open a pull request. + + The PR must link issue #${{ github.event.issue.number }}. + + Do not merge the pull request. + Final merge must be manual by a maintainer. \ No newline at end of file diff --git a/.github/workflows/opencode-pr-review.yml b/.github/workflows/opencode-pr-review.yml new file mode 100644 index 00000000..cde27e05 --- /dev/null +++ b/.github/workflows/opencode-pr-review.yml @@ -0,0 +1,43 @@ +# .github/workflows/opencode-pr-review.yml +name: OpenCode PR Review + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + id-token: write + contents: read + pull-requests: write + issues: write + +jobs: + review: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Run OpenCode PR reviewer + uses: anomalyco/opencode/github@latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + with: + use_github_token: true + agent: pr-review + model: opencode/big-pickle + prompt: | + Review this pull request. + + Do not modify files. + Do not commit. + Do not push. + Do not merge. + + Focus on correctness, security, reliability, maintainability, and tests. \ No newline at end of file diff --git a/.opencode/agents/issue-implementer.md b/.opencode/agents/issue-implementer.md new file mode 100644 index 00000000..4ca37180 --- /dev/null +++ b/.opencode/agents/issue-implementer.md @@ -0,0 +1,105 @@ +# .opencode/agents/issue-implementer.md +--- +description: Implements a planned GitHub issue by reading the latest OpenCode Plan issue comment, then opens a PR. +mode: primary +temperature: 0.2 +permission: + read: allow + list: allow + glob: allow + grep: allow + lsp: allow + edit: allow + external_directory: deny + todowrite: allow + webfetch: deny + websearch: deny + bash: + "*": deny + "git status*": allow + "git diff*": allow + "git log*": allow + "git show*": allow + "git checkout -b*": allow + "git add*": allow + "git commit*": allow + "git push*": allow + "gh issue view*": allow + "gh issue comment*": allow + "gh pr create*": allow + "gh pr view*": allow + "npm test*": allow + "npm run test*": allow + "npm run lint*": allow + "npm run typecheck*": allow + "npm run build*": allow + "pnpm test*": allow + "pnpm run test*": allow + "pnpm run lint*": allow + "pnpm run typecheck*": allow + "pnpm run build*": allow + "yarn test*": allow + "yarn lint*": allow + "yarn typecheck*": allow + "yarn build*": allow + "pytest*": allow + "go test*": allow + "cargo test*": allow +--- + +You are an issue implementation agent. + +Your job is to implement a GitHub issue only after an OpenCode Plan exists on the issue. + +Before editing anything: +1. Run `gh issue view --comments`. +2. Find the latest issue comment containing ``. +3. Extract the plan between: + - `` + - `` +4. If no plan exists, do not modify files. Comment on the issue explaining that an OpenCode Plan is required first. +5. If the plan says `Ready for implementation` is `No`, do not modify files. Comment on the issue with the blockers. +6. If the plan says `Ready for implementation` is `Yes`, follow the plan. + +You must: +1. Read the issue. +2. Read the latest OpenCode Plan issue comment. +3. Create a new branch. +4. Implement the smallest correct change. +5. Add or update tests when behavior changes. +6. Run relevant checks. +7. Commit the changes. +8. Push the branch. +9. Open a pull request linked to the issue. + +You must not: +- Merge the pull request +- Force push +- Delete branches +- Modify unrelated files +- Perform unrelated refactors +- Ignore the plan without explaining why + +Branch naming: +- Use `opencode/issue--short-description`. + +Commit message: +- Use a conventional commit prefix when possible. +- Include `Refs #` in the commit body. + +Pull request body must include: + +## Summary +Explain what changed. + +## Plan followed +Paste or summarize the OpenCode Plan used. + +## Tests +List commands run and results. + +## Risk +Mention compatibility, security, migration, performance, or operational risks. + +## Manual merge required +This PR was created by OpenCode and must be reviewed and merged manually by a maintainer. \ No newline at end of file diff --git a/.opencode/agents/pr-review.md b/.opencode/agents/pr-review.md new file mode 100644 index 00000000..4df00167 --- /dev/null +++ b/.opencode/agents/pr-review.md @@ -0,0 +1,60 @@ +# .opencode/agents/pr-review.md +--- +description: Reviews pull requests for correctness, security, reliability, maintainability, and tests. +mode: primary +temperature: 0.1 +permission: + read: allow + list: allow + glob: allow + grep: allow + lsp: allow + edit: deny + external_directory: deny + todowrite: deny + webfetch: deny + websearch: deny + bash: + "*": deny + "git status*": allow + "git diff*": allow + "git log*": allow + "git show*": allow + "git branch*": allow + "gh pr view*": allow + "gh pr diff*": allow + "gh pr checks*": allow +--- + +You are a senior pull request reviewer. + +Review the PR only. Do not edit files, apply patches, commit, push, or merge. + +Focus on: +- Correctness +- Security and privacy +- Reliability +- Maintainability +- Missing or weak tests + +Return: + +## PR Review + +### Summary +Briefly summarize what changed. + +### Blocking issues +List Critical or High issues that should block merge. + +### Non-blocking issues +List Medium or Low issues worth addressing. + +### Missing tests +List specific test cases that should be added. + +### Verdict +Choose one: +- Approve +- Approve with comments +- Request changes \ No newline at end of file From 6f07b3e19f7c0f2c49d6b99c69db3d171d8b7add Mon Sep 17 00:00:00 2001 From: Fernando Pinto Date: Sun, 3 May 2026 12:24:24 +0100 Subject: [PATCH 03/15] fix opencode branch permissions --- .../workflows/opencode-issue-implement.yml | 12 +++- .opencode/agents/issue-implementer.md | 60 +++++++------------ 2 files changed, 31 insertions(+), 41 deletions(-) diff --git a/.github/workflows/opencode-issue-implement.yml b/.github/workflows/opencode-issue-implement.yml index 6c9d80ee..59616b2c 100644 --- a/.github/workflows/opencode-issue-implement.yml +++ b/.github/workflows/opencode-issue-implement.yml @@ -56,9 +56,15 @@ jobs: 4. If no plan exists, do not modify files. 5. If the plan says "Ready for implementation: No", do not modify files. - 6. If ready, implement the smallest correct change, add or update tests, run relevant checks, commit, push a branch, and open a pull request. + 6. If ready, implement the smallest correct change, add or update tests, and run relevant checks. - The PR must link issue #${{ github.event.issue.number }}. + Do not create a branch. + Do not switch branches. + Do not push. + Do not manually create a pull request. + + The OpenCode GitHub Action has already checked out the correct working branch. + Leave the intended changes in the working tree. + The action will handle publishing the branch and PR. - Do not merge the pull request. Final merge must be manual by a maintainer. \ No newline at end of file diff --git a/.opencode/agents/issue-implementer.md b/.opencode/agents/issue-implementer.md index 4ca37180..2306be26 100644 --- a/.opencode/agents/issue-implementer.md +++ b/.opencode/agents/issue-implementer.md @@ -1,6 +1,6 @@ # .opencode/agents/issue-implementer.md --- -description: Implements a planned GitHub issue by reading the latest OpenCode Plan issue comment, then opens a PR. +description: Implements a planned GitHub issue by reading the latest OpenCode Plan issue comment. OpenCode GitHub Action handles branch, push, and PR creation. mode: primary temperature: 0.2 permission: @@ -20,14 +20,8 @@ permission: "git diff*": allow "git log*": allow "git show*": allow - "git checkout -b*": allow - "git add*": allow - "git commit*": allow - "git push*": allow "gh issue view*": allow "gh issue comment*": allow - "gh pr create*": allow - "gh pr view*": allow "npm test*": allow "npm run test*": allow "npm run lint*": allow @@ -51,6 +45,14 @@ You are an issue implementation agent. Your job is to implement a GitHub issue only after an OpenCode Plan exists on the issue. +Important: +- Do not create a branch. +- Do not switch branches. +- Do not push. +- Do not open a pull request manually. +- The OpenCode GitHub Action already creates the working branch and will handle publishing the result. +- Stay on the branch that already exists when the session starts. + Before editing anything: 1. Run `gh issue view --comments`. 2. Find the latest issue comment containing ``. @@ -64,42 +66,24 @@ Before editing anything: You must: 1. Read the issue. 2. Read the latest OpenCode Plan issue comment. -3. Create a new branch. -4. Implement the smallest correct change. -5. Add or update tests when behavior changes. -6. Run relevant checks. -7. Commit the changes. -8. Push the branch. -9. Open a pull request linked to the issue. +3. Implement the smallest correct change. +4. Add or update tests when behavior changes. +5. Run relevant checks. +6. Leave the final working tree with only the intended changes. You must not: -- Merge the pull request +- Merge anything +- Create or switch branches +- Push - Force push - Delete branches +- Manually create a pull request - Modify unrelated files - Perform unrelated refactors - Ignore the plan without explaining why -Branch naming: -- Use `opencode/issue--short-description`. - -Commit message: -- Use a conventional commit prefix when possible. -- Include `Refs #` in the commit body. - -Pull request body must include: - -## Summary -Explain what changed. - -## Plan followed -Paste or summarize the OpenCode Plan used. - -## Tests -List commands run and results. - -## Risk -Mention compatibility, security, migration, performance, or operational risks. - -## Manual merge required -This PR was created by OpenCode and must be reviewed and merged manually by a maintainer. \ No newline at end of file +When finished, summarize: +1. Files changed +2. Tests run +3. Any risks or follow-up needed +4. That the final merge must be manual by a maintainer \ No newline at end of file From 86b56ce3e7e39b59df88b037c32ad53911be13cb Mon Sep 17 00:00:00 2001 From: Fernando Pinto Date: Sun, 3 May 2026 13:09:48 +0100 Subject: [PATCH 04/15] fix pr permissions --- .github/workflows/opencode-issue-implement.yml | 2 +- .github/workflows/opencode-issue-plan.yml | 2 +- .github/workflows/opencode-pr-review.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/opencode-issue-implement.yml b/.github/workflows/opencode-issue-implement.yml index 59616b2c..a4382652 100644 --- a/.github/workflows/opencode-issue-implement.yml +++ b/.github/workflows/opencode-issue-implement.yml @@ -34,7 +34,7 @@ jobs: - name: Implement issue from latest OpenCode Plan comment uses: anomalyco/opencode/github@latest env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.OPENCODE_GITHUB_TOKEN }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} with: use_github_token: true diff --git a/.github/workflows/opencode-issue-plan.yml b/.github/workflows/opencode-issue-plan.yml index 6c24f5a3..f078bbd5 100644 --- a/.github/workflows/opencode-issue-plan.yml +++ b/.github/workflows/opencode-issue-plan.yml @@ -29,7 +29,7 @@ jobs: - name: Create implementation plan as issue comment uses: anomalyco/opencode/github@latest env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.OPENCODE_GITHUB_TOKEN }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} with: use_github_token: true diff --git a/.github/workflows/opencode-pr-review.yml b/.github/workflows/opencode-pr-review.yml index cde27e05..baaa41c1 100644 --- a/.github/workflows/opencode-pr-review.yml +++ b/.github/workflows/opencode-pr-review.yml @@ -26,7 +26,7 @@ jobs: - name: Run OpenCode PR reviewer uses: anomalyco/opencode/github@latest env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.OPENCODE_GITHUB_TOKEN }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} with: use_github_token: true From 30ad6b7716cceb304f166cc6b9afe98f1b45e1bc Mon Sep 17 00:00:00 2001 From: Fernando Pinto Date: Sun, 3 May 2026 15:52:17 +0100 Subject: [PATCH 05/15] fix review --- .github/workflows/opencode-pr-review.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/opencode-pr-review.yml b/.github/workflows/opencode-pr-review.yml index baaa41c1..7b11bb9b 100644 --- a/.github/workflows/opencode-pr-review.yml +++ b/.github/workflows/opencode-pr-review.yml @@ -21,12 +21,13 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 - persist-credentials: false + token: ${{ secrets.OPENCODE_GITHUB_TOKEN }} + persist-credentials: true - name: Run OpenCode PR reviewer uses: anomalyco/opencode/github@latest env: - GITHUB_TOKEN: ${{ secrets.OPENCODE_GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.OPENCODE_GITHUB_TOKEN }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} with: use_github_token: true From 21146ae560bd60b2856fcb6ce8591cc8538d9301 Mon Sep 17 00:00:00 2001 From: Fernando Pinto Date: Sun, 3 May 2026 16:12:30 +0100 Subject: [PATCH 06/15] use oc commands Co-authored-by: Copilot --- .github/workflows/opencode.yml | 146 +++++++++++++++++++++++++++++++-- 1 file changed, 137 insertions(+), 9 deletions(-) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 07d33d76..e1ee8629 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -7,27 +7,155 @@ on: types: [created] jobs: - opencode: + plan: if: | - contains(github.event.comment.body, ' /oc') || - startsWith(github.event.comment.body, '/oc') || - contains(github.event.comment.body, ' /opencode') || - startsWith(github.event.comment.body, '/opencode') + github.event_name == 'issue_comment' && + github.event.issue.pull_request == null && + ( + contains(github.event.comment.body, ' /oc plan') || + startsWith(github.event.comment.body, '/oc plan') || + contains(github.event.comment.body, ' /opencode plan') || + startsWith(github.event.comment.body, '/opencode plan') + ) runs-on: ubuntu-latest permissions: id-token: write contents: read - pull-requests: read - issues: read + issues: write steps: - name: Checkout repository uses: actions/checkout@v6 with: persist-credentials: false - - name: Run opencode + - name: Run OpenCode issue planner uses: anomalyco/opencode/github@latest env: + GITHUB_TOKEN: ${{ secrets.OPENCODE_GITHUB_TOKEN }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} with: - model: opencode/big-pickle \ No newline at end of file + use_github_token: true + agent: issue-planner + model: opencode/big-pickle + prompt: | + Create an implementation plan for issue #${{ github.event.issue.number }}. + + Store the plan as a GitHub issue comment. + The comment must include exactly one block wrapped with: + + + + + Do not modify files. + Do not create a branch. + Do not open a pull request. + + implement: + if: | + github.event_name == 'issue_comment' && + github.event.issue.pull_request == null && + ( + contains(github.event.comment.body, ' /oc implement') || + startsWith(github.event.comment.body, '/oc implement') || + contains(github.event.comment.body, ' /opencode implement') || + startsWith(github.event.comment.body, '/opencode implement') + ) + runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + pull-requests: write + issues: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Configure Git author + run: | + git config user.name "opencode[bot]" + git config user.email "opencode[bot]@users.noreply.github.com" + + - name: Run OpenCode issue implementer + uses: anomalyco/opencode/github@latest + env: + GITHUB_TOKEN: ${{ secrets.OPENCODE_GITHUB_TOKEN }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + with: + use_github_token: true + agent: issue-implementer + model: opencode/big-pickle + prompt: | + Implement issue #${{ github.event.issue.number }}. + + Before editing files: + 1. Read the issue and comments. + 2. Find the latest issue comment containing: + + + + 3. Extract the plan between: + + + + + 4. If no plan exists, do not modify files. + 5. If the plan says "Ready for implementation: No", do not modify files. + 6. If ready, implement the smallest correct change, add or update tests, and run relevant checks. + + Do not create a branch. + Do not switch branches. + Do not push. + Do not manually create a pull request. + + The OpenCode GitHub Action has already checked out the correct working branch. + Leave the intended changes in the working tree. + The action will handle publishing the branch and PR. + + Final merge must be manual by a maintainer. + + review: + if: | + ( + github.event_name == 'pull_request_review_comment' || + (github.event_name == 'issue_comment' && github.event.issue.pull_request != null) + ) && + ( + contains(github.event.comment.body, ' /oc review') || + startsWith(github.event.comment.body, '/oc review') || + contains(github.event.comment.body, ' /opencode review') || + startsWith(github.event.comment.body, '/opencode review') + ) + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + pull-requests: write + issues: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.OPENCODE_GITHUB_TOKEN }} + persist-credentials: true + + - name: Run OpenCode PR reviewer + uses: anomalyco/opencode/github@latest + env: + GITHUB_TOKEN: ${{ secrets.OPENCODE_GITHUB_TOKEN }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + with: + use_github_token: true + agent: pr-review + model: opencode/big-pickle + prompt: | + Review this pull request. + + Do not modify files. + Do not commit. + Do not push. + Do not merge. + + Focus on correctness, security, reliability, maintainability, and tests. \ No newline at end of file From 1974d2868aa38b8988620b72f10287aa9c510960 Mon Sep 17 00:00:00 2001 From: Fernando Pinto Date: Sun, 3 May 2026 16:16:51 +0100 Subject: [PATCH 07/15] Revert "use oc commands" This reverts commit 21146ae560bd60b2856fcb6ce8591cc8538d9301. --- .github/workflows/opencode.yml | 146 ++------------------------------- 1 file changed, 9 insertions(+), 137 deletions(-) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index e1ee8629..07d33d76 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -7,155 +7,27 @@ on: types: [created] jobs: - plan: + opencode: if: | - github.event_name == 'issue_comment' && - github.event.issue.pull_request == null && - ( - contains(github.event.comment.body, ' /oc plan') || - startsWith(github.event.comment.body, '/oc plan') || - contains(github.event.comment.body, ' /opencode plan') || - startsWith(github.event.comment.body, '/opencode plan') - ) + contains(github.event.comment.body, ' /oc') || + startsWith(github.event.comment.body, '/oc') || + contains(github.event.comment.body, ' /opencode') || + startsWith(github.event.comment.body, '/opencode') runs-on: ubuntu-latest permissions: id-token: write contents: read - issues: write + pull-requests: read + issues: read steps: - name: Checkout repository uses: actions/checkout@v6 with: persist-credentials: false - - name: Run OpenCode issue planner + - name: Run opencode uses: anomalyco/opencode/github@latest env: - GITHUB_TOKEN: ${{ secrets.OPENCODE_GITHUB_TOKEN }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} with: - use_github_token: true - agent: issue-planner - model: opencode/big-pickle - prompt: | - Create an implementation plan for issue #${{ github.event.issue.number }}. - - Store the plan as a GitHub issue comment. - The comment must include exactly one block wrapped with: - - - - - Do not modify files. - Do not create a branch. - Do not open a pull request. - - implement: - if: | - github.event_name == 'issue_comment' && - github.event.issue.pull_request == null && - ( - contains(github.event.comment.body, ' /oc implement') || - startsWith(github.event.comment.body, '/oc implement') || - contains(github.event.comment.body, ' /opencode implement') || - startsWith(github.event.comment.body, '/opencode implement') - ) - runs-on: ubuntu-latest - permissions: - id-token: write - contents: write - pull-requests: write - issues: write - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Configure Git author - run: | - git config user.name "opencode[bot]" - git config user.email "opencode[bot]@users.noreply.github.com" - - - name: Run OpenCode issue implementer - uses: anomalyco/opencode/github@latest - env: - GITHUB_TOKEN: ${{ secrets.OPENCODE_GITHUB_TOKEN }} - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - with: - use_github_token: true - agent: issue-implementer - model: opencode/big-pickle - prompt: | - Implement issue #${{ github.event.issue.number }}. - - Before editing files: - 1. Read the issue and comments. - 2. Find the latest issue comment containing: - - - - 3. Extract the plan between: - - - - - 4. If no plan exists, do not modify files. - 5. If the plan says "Ready for implementation: No", do not modify files. - 6. If ready, implement the smallest correct change, add or update tests, and run relevant checks. - - Do not create a branch. - Do not switch branches. - Do not push. - Do not manually create a pull request. - - The OpenCode GitHub Action has already checked out the correct working branch. - Leave the intended changes in the working tree. - The action will handle publishing the branch and PR. - - Final merge must be manual by a maintainer. - - review: - if: | - ( - github.event_name == 'pull_request_review_comment' || - (github.event_name == 'issue_comment' && github.event.issue.pull_request != null) - ) && - ( - contains(github.event.comment.body, ' /oc review') || - startsWith(github.event.comment.body, '/oc review') || - contains(github.event.comment.body, ' /opencode review') || - startsWith(github.event.comment.body, '/opencode review') - ) - runs-on: ubuntu-latest - permissions: - id-token: write - contents: read - pull-requests: write - issues: write - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - fetch-depth: 0 - token: ${{ secrets.OPENCODE_GITHUB_TOKEN }} - persist-credentials: true - - - name: Run OpenCode PR reviewer - uses: anomalyco/opencode/github@latest - env: - GITHUB_TOKEN: ${{ secrets.OPENCODE_GITHUB_TOKEN }} - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - with: - use_github_token: true - agent: pr-review - model: opencode/big-pickle - prompt: | - Review this pull request. - - Do not modify files. - Do not commit. - Do not push. - Do not merge. - - Focus on correctness, security, reliability, maintainability, and tests. \ No newline at end of file + model: opencode/big-pickle \ No newline at end of file From 939212bd4a66e15c96480dfc0da8e7ad7f1fe183 Mon Sep 17 00:00:00 2001 From: Fernando Pinto Date: Sun, 3 May 2026 16:26:28 +0100 Subject: [PATCH 08/15] wip Co-authored-by: Copilot --- .github/workflows/opencode.yml | 35 ++++++++++++++++---- .opencode/agents/issue-implementer.md | 29 +++++++++++++---- .opencode/agents/triage.md | 47 +++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 14 deletions(-) create mode 100644 .opencode/agents/triage.md diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 07d33d76..8eecd844 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -7,7 +7,7 @@ on: types: [created] jobs: - opencode: + triage: if: | contains(github.event.comment.body, ' /oc') || startsWith(github.event.comment.body, '/oc') || @@ -16,18 +16,39 @@ jobs: runs-on: ubuntu-latest permissions: id-token: write - contents: read - pull-requests: read - issues: read + contents: write + pull-requests: write + issues: write steps: - name: Checkout repository uses: actions/checkout@v6 with: - persist-credentials: false + fetch-depth: 0 + token: ${{ secrets.OPENCODE_GITHUB_TOKEN }} + persist-credentials: true - - name: Run opencode + - name: Configure Git author + run: | + git config user.name "opencode[bot]" + git config user.email "opencode[bot]@users.noreply.github.com" + + - name: Run OpenCode triage agent uses: anomalyco/opencode/github@latest env: + GITHUB_TOKEN: ${{ secrets.OPENCODE_GITHUB_TOKEN }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} with: - model: opencode/big-pickle \ No newline at end of file + use_github_token: true + agent: triage + model: opencode/big-pickle + prompt: | + A comment was posted with the following body: + + ${{ github.event.comment.body }} + + Context: + - Event: ${{ github.event_name }} + - Issue / PR number: ${{ github.event.issue.number || github.event.pull_request.number }} + - Is pull request: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_review_comment' }} + + Read the comment, identify the command, and delegate to the correct agent. \ No newline at end of file diff --git a/.opencode/agents/issue-implementer.md b/.opencode/agents/issue-implementer.md index 2306be26..c2ce96b6 100644 --- a/.opencode/agents/issue-implementer.md +++ b/.opencode/agents/issue-implementer.md @@ -22,6 +22,9 @@ permission: "git show*": allow "gh issue view*": allow "gh issue comment*": allow + "gh pr view*": allow + "gh pr comment*": allow + "gh pr review*": allow "npm test*": allow "npm run test*": allow "npm run lint*": allow @@ -43,7 +46,9 @@ permission: You are an issue implementation agent. -Your job is to implement a GitHub issue only after an OpenCode Plan exists on the issue. +Your job is to implement changes from two possible sources: +1. A GitHub issue with an OpenCode Plan comment. +2. Review comments or suggestions left on a pull request. Important: - Do not create a branch. @@ -53,6 +58,8 @@ Important: - The OpenCode GitHub Action already creates the working branch and will handle publishing the result. - Stay on the branch that already exists when the session starts. +## When triggered from an issue + Before editing anything: 1. Run `gh issue view --comments`. 2. Find the latest issue comment containing ``. @@ -63,13 +70,21 @@ Before editing anything: 5. If the plan says `Ready for implementation` is `No`, do not modify files. Comment on the issue with the blockers. 6. If the plan says `Ready for implementation` is `Yes`, follow the plan. +## When triggered from a pull request + +Before editing anything: +1. Run `gh pr view --comments` to read all review comments and suggestions. +2. Collect every unresolved review comment and inline suggestion. +3. Address each one with the smallest correct code change. +4. Do not change code that has no corresponding review comment. +5. After applying fixes, run relevant checks. + You must: -1. Read the issue. -2. Read the latest OpenCode Plan issue comment. -3. Implement the smallest correct change. -4. Add or update tests when behavior changes. -5. Run relevant checks. -6. Leave the final working tree with only the intended changes. +1. Read the issue or PR and its comments. +2. Implement the smallest correct change for each item. +3. Add or update tests when behavior changes. +4. Run relevant checks. +5. Leave the final working tree with only the intended changes. You must not: - Merge anything diff --git a/.opencode/agents/triage.md b/.opencode/agents/triage.md new file mode 100644 index 00000000..361c2e45 --- /dev/null +++ b/.opencode/agents/triage.md @@ -0,0 +1,47 @@ +# .opencode/agents/triage.md +--- +description: Reads the triggering comment command and delegates to the correct specialist agent — issue-planner, issue-implementer, or pr-review. +mode: primary +temperature: 0.1 +permission: + read: allow + list: allow + glob: allow + grep: allow + lsp: allow + edit: deny + external_directory: deny + todowrite: deny + webfetch: deny + websearch: deny + bash: + "*": deny + "gh issue view*": allow + "gh pr view*": allow +--- + +You are a triage agent. Your only job is to read the comment that triggered this workflow and hand off to the right specialist agent. You do not plan, implement, or review anything yourself. + +## Routing rules + +Read the comment body provided in the prompt. Match it to one of the commands below (case-insensitive, anywhere in the text): + +| Command | Delegate to agent | +|---------|------------------| +| `/oc plan` or `/opencode plan` | `issue-planner` (issues only) | +| `/oc implement` or `/opencode implement` | `issue-implementer` (issues: follow plan; PRs: fix review comments) | +| `/oc review` or `/opencode review` | `pr-review` (PRs only) | + +## Behavior + +1. Identify which command is present in the comment. +2. State clearly which agent you are delegating to and why. +3. Invoke that agent with the full original context (issue number or PR number, comment body). +4. Do not modify files, create branches, push, or perform any action outside delegation. + +If the comment does not match any known command, reply to the comment explaining the available commands: +- `/oc plan` — create an implementation plan for this issue +- `/oc implement` — implement the existing plan for this issue +- `/oc review` — review this pull request + +Do not guess or infer intent beyond what is explicitly stated in the comment. From 0866bed8ee12e0f3dc1dadafe4ae15b74134b7f8 Mon Sep 17 00:00:00 2001 From: Fernando Pinto Date: Sun, 3 May 2026 16:34:17 +0100 Subject: [PATCH 09/15] wip Co-authored-by: Copilot --- .opencode/agents/issue-implementer.md | 2 +- .opencode/agents/issue-planner.md | 2 +- .opencode/agents/pr-review.md | 2 +- .opencode/agents/triage.md | 12 +++++++++--- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.opencode/agents/issue-implementer.md b/.opencode/agents/issue-implementer.md index c2ce96b6..e4c72df8 100644 --- a/.opencode/agents/issue-implementer.md +++ b/.opencode/agents/issue-implementer.md @@ -1,7 +1,7 @@ # .opencode/agents/issue-implementer.md --- description: Implements a planned GitHub issue by reading the latest OpenCode Plan issue comment. OpenCode GitHub Action handles branch, push, and PR creation. -mode: primary +mode: subagent temperature: 0.2 permission: read: allow diff --git a/.opencode/agents/issue-planner.md b/.opencode/agents/issue-planner.md index 5def503b..456cc00f 100644 --- a/.opencode/agents/issue-planner.md +++ b/.opencode/agents/issue-planner.md @@ -1,7 +1,7 @@ # .opencode/agents/issue-planner.md --- description: Creates an implementation plan for a GitHub issue without modifying files. -mode: primary +mode: subagent temperature: 0.2 permission: read: allow diff --git a/.opencode/agents/pr-review.md b/.opencode/agents/pr-review.md index 4df00167..204bb782 100644 --- a/.opencode/agents/pr-review.md +++ b/.opencode/agents/pr-review.md @@ -1,7 +1,7 @@ # .opencode/agents/pr-review.md --- description: Reviews pull requests for correctness, security, reliability, maintainability, and tests. -mode: primary +mode: subagent temperature: 0.1 permission: read: allow diff --git a/.opencode/agents/triage.md b/.opencode/agents/triage.md index 361c2e45..09b43902 100644 --- a/.opencode/agents/triage.md +++ b/.opencode/agents/triage.md @@ -14,6 +14,11 @@ permission: todowrite: deny webfetch: deny websearch: deny + task: + "*": deny + "issue-planner": allow + "issue-implementer": allow + "pr-review": allow bash: "*": deny "gh issue view*": allow @@ -35,9 +40,10 @@ Read the comment body provided in the prompt. Match it to one of the commands be ## Behavior 1. Identify which command is present in the comment. -2. State clearly which agent you are delegating to and why. -3. Invoke that agent with the full original context (issue number or PR number, comment body). -4. Do not modify files, create branches, push, or perform any action outside delegation. +2. Select the matching subagent from the routing table above. +3. Use the **Task tool** to invoke that subagent, passing the full original context (issue number or PR number, comment body, event type). +4. Wait for the subagent to finish and return its result. +5. Do not modify files, create branches, push, or perform any action outside delegation. If the comment does not match any known command, reply to the comment explaining the available commands: - `/oc plan` — create an implementation plan for this issue From 3d730c7a6ed29f870b2a422689d1a4269f27f9b7 Mon Sep 17 00:00:00 2001 From: Fernando Pinto Date: Sun, 3 May 2026 17:06:28 +0100 Subject: [PATCH 10/15] wip Co-authored-by: Copilot --- .opencode/agents/triage.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/.opencode/agents/triage.md b/.opencode/agents/triage.md index 09b43902..21245ef1 100644 --- a/.opencode/agents/triage.md +++ b/.opencode/agents/triage.md @@ -34,16 +34,25 @@ Read the comment body provided in the prompt. Match it to one of the commands be | Command | Delegate to agent | |---------|------------------| | `/oc plan` or `/opencode plan` | `issue-planner` (issues only) | -| `/oc implement` or `/opencode implement` | `issue-implementer` (issues: follow plan; PRs: fix review comments) | +| `/oc implement`, `/opencode implement`, `/oc implment`, or `/opencode implment` | `issue-implementer` (issues: follow plan; PRs: fix review comments) | | `/oc review` or `/opencode review` | `pr-review` (PRs only) | ## Behavior 1. Identify which command is present in the comment. 2. Select the matching subagent from the routing table above. -3. Use the **Task tool** to invoke that subagent, passing the full original context (issue number or PR number, comment body, event type). -4. Wait for the subagent to finish and return its result. -5. Do not modify files, create branches, push, or perform any action outside delegation. +3. Use the **Task tool** to invoke only that exact subagent name: `issue-planner`, `issue-implementer`, or `pr-review`. +4. Never invoke built-in subagents such as `general` or `explore` for command routing. +5. Pass the full original context to the delegated subagent (issue number or PR number, comment body, event type). +6. Wait for the subagent to finish and return its result. +7. Do not modify files, create branches, push, or perform any action outside delegation. + +## Strict command mapping + +- If the comment contains a plan command, delegate to `issue-planner`. +- If the comment contains an implement command (`implement` or `implment`), delegate to `issue-implementer`. +- If the comment contains a review command, delegate to `pr-review`. +- If none match, do not delegate and post help text. If the comment does not match any known command, reply to the comment explaining the available commands: - `/oc plan` — create an implementation plan for this issue From 29788cbe3bdb8edb20f3146f43d0d449e8d50819 Mon Sep 17 00:00:00 2001 From: Fernando Pinto Date: Sun, 3 May 2026 17:41:41 +0100 Subject: [PATCH 11/15] wip Co-authored-by: Copilot --- README.md | 180 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 178 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fec311e4..65cf1940 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,178 @@ -# opencode-sdlc -# opencode-sdlc +# OpenCode SDLC PoC on GitHub Actions + +This repository is a proof of concept that shows how to run AI-assisted SDLC workflows on GitHub using OpenCode GitHub Action, instead of relying on Copilot-specific workflows. + +Reference documentation: +- https://opencode.ai/docs/github/ + +## Motivation + +After GitHub [announced](https://github.blog/changelog/2025-04-04-changes-to-copilot-plans-and-pricing/) that Copilot prices are increasing **as of July 1, 2026**, I started looking for a cheaper alternative that still lets me have AI-assisted workflows in GitHub for my personal weekend projects. + +[OpenCode](https://opencode.ai) caught my attention: it's an open-source AI coding tool that supports multiple model providers, meaning you can plug in whatever model fits your budget — including cheap or free-tier options — without being locked into a single vendor. + +This repository is my weekend experiment to see if I can replicate the core Copilot-for-PRs/issues developer loop using OpenCode running inside GitHub Actions: + +- No Copilot subscription required +- Bring your own model and API key +- Full control over agent behavior via config files in the repo +- Works with any model provider supported by OpenCode + +## What This PoC Demonstrates + +- Comment-driven commands in GitHub (`/oc ...` or `/opencode ...`) +- A triage agent that delegates work to specialist subagents +- Separate agent behaviors for planning, implementation, and review +- Full automation loop for implementation: code changes, branch update, and PR creation via the OpenCode GitHub Action + +## Architecture + +### Workflow entrypoint + +- Workflow: [.github/workflows/opencode.yml](.github/workflows/opencode.yml) +- Triggered by: + - `issue_comment` created + - `pull_request_review_comment` created +- Runs `anomalyco/opencode/github@latest` with `agent: triage` + +### Agent routing + +- Triage agent: [.opencode/agents/triage.md](.opencode/agents/triage.md) +- Delegates by command to subagents: + - `issue-planner` -> [.opencode/agents/issue-planner.md](.opencode/agents/issue-planner.md) + - `issue-implementer` -> [.opencode/agents/issue-implementer.md](.opencode/agents/issue-implementer.md) + - `pr-review` -> [.opencode/agents/pr-review.md](.opencode/agents/pr-review.md) + +## Commands + +Use these in issue or PR comments: + +```text +/oc plan +/oc implement +/oc review +``` + +Aliases: + +```text +/opencode plan +/opencode implement +/opencode review +``` + +Common typo supported in triage mapping: + +```text +/oc implment +/opencode implment +``` + +## Behavior by Command + +### 1) Plan + +Comment on an issue: + +```text +/oc plan +``` + +Expected behavior: +- Triage delegates to `issue-planner` +- Planner reads issue and repository context +- Planner posts a structured implementation plan back to the issue + +### 2) Implement + +Comment on an issue: + +```text +/oc implement +``` + +Expected behavior: +- Triage delegates to `issue-implementer` +- Implementer reads the latest plan block in issue comments +- Implements the smallest correct change +- Runs relevant checks/tests +- Leaves intended changes for action-managed branch/PR flow + +Comment on a PR thread (for review fixups): + +```text +/oc implement please address review comments +``` + +Expected behavior: +- Triage delegates to `issue-implementer` +- Implementer reads PR comments/suggestions +- Applies targeted fixes and runs checks + +### 3) Review + +Comment on a PR: + +```text +/oc review +``` + +Expected behavior: +- Triage delegates to `pr-review` +- Reviewer analyzes correctness, security, reliability, maintainability, and tests +- Reviewer posts review output + +## Required Secrets + +Configure these repository secrets: + +| Secret | Description | +|--------|-------------| +| `OPENCODE_API_KEY` | API key for the OpenCode model provider | +| `OPENCODE_GITHUB_TOKEN` | Fine-grained PAT from a bot/service account (see below) | + +### Creating `OPENCODE_GITHUB_TOKEN` + +1. Go to **GitHub Settings → Developer settings → Personal access tokens → Fine-grained tokens** +2. Create a token from a dedicated bot or service account +3. Set repository access to this repository (or your org's repositories as needed) +4. Grant the following permissions: + +| Permission | Access | +|------------|--------| +| **Contents** | Read and write | +| **Pull requests** | Read and write | +| **Issues** | Read and write | +| **Metadata** | Read (mandatory, auto-selected) | + +5. Save the generated token as the `OPENCODE_GITHUB_TOKEN` repository secret + +## Minimal Setup Checklist + +1. Add workflow file [.github/workflows/opencode.yml](.github/workflows/opencode.yml). +2. Add agent definitions under [.opencode/agents](.opencode/agents). +3. Add required secrets in repository settings. +4. Open an issue and comment with `/oc plan`. +5. After plan is ready, comment `/oc implement`. +6. On a PR, comment `/oc review`. + +## Example Demo Script + +1. Create issue: "Add login endpoint" +2. Comment: `/oc plan` +3. Show generated plan comment +4. Comment: `/oc implement` +5. Show generated code changes and PR +6. Comment on PR: `/oc review` +7. Show AI review feedback +8. Add an inline PR suggestion and comment `/oc implement` to show iterative fix flow + +## Notes + +- This repository still contains dedicated workflows: + - [.github/workflows/opencode-issue-plan.yml](.github/workflows/opencode-issue-plan.yml) + - [.github/workflows/opencode-issue-implement.yml](.github/workflows/opencode-issue-implement.yml) + - [.github/workflows/opencode-pr-review.yml](.github/workflows/opencode-pr-review.yml) +- For a cleaner PoC narrative, you can keep only the triage-driven workflow and disable the others. + + From 06375096c85d47c08c833c002d4af54474d4e117 Mon Sep 17 00:00:00 2001 From: Fernando Pinto Date: Sun, 3 May 2026 18:03:13 +0100 Subject: [PATCH 12/15] wip Co-authored-by: Copilot --- .github/workflows/opencode.yml | 11 +++++++---- README.md | 36 ++++++++++++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 8eecd844..ce085845 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -9,10 +9,13 @@ on: jobs: triage: if: | - contains(github.event.comment.body, ' /oc') || - startsWith(github.event.comment.body, '/oc') || - contains(github.event.comment.body, ' /opencode') || - startsWith(github.event.comment.body, '/opencode') + github.event.comment.user.login == github.repository_owner && + ( + contains(github.event.comment.body, ' /oc') || + startsWith(github.event.comment.body, '/oc') || + contains(github.event.comment.body, ' /opencode') || + startsWith(github.event.comment.body, '/opencode') + ) runs-on: ubuntu-latest permissions: id-token: write diff --git a/README.md b/README.md index 65cf1940..0ba49c77 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,11 @@ Reference documentation: ## Motivation -After GitHub [announced](https://github.blog/changelog/2025-04-04-changes-to-copilot-plans-and-pricing/) that Copilot prices are increasing **as of July 1, 2026**, I started looking for a cheaper alternative that still lets me have AI-assisted workflows in GitHub for my personal weekend projects. +After GitHub [announced](https://github.blog/changelog/2025-04-04-changes-to-copilot-plans-and-pricing/) that Copilot prices are increasing **as of July 1, 2026**, I started looking for a cheaper alternative that still lets me have fun and keep AI-assisted workflows going in GitHub for my personal weekend projects. [OpenCode](https://opencode.ai) caught my attention: it's an open-source AI coding tool that supports multiple model providers, meaning you can plug in whatever model fits your budget — including cheap or free-tier options — without being locked into a single vendor. -This repository is my weekend experiment to see if I can replicate the core Copilot-for-PRs/issues developer loop using OpenCode running inside GitHub Actions: +This repository is my weekend experiment to see if I can replicate the core Copilot-for-PRs/issues developer loop using OpenCode running inside GitHub Actions — and actually have fun doing it: - No Copilot subscription required - Bring your own model and API key @@ -175,4 +175,36 @@ Configure these repository secrets: - [.github/workflows/opencode-pr-review.yml](.github/workflows/opencode-pr-review.yml) - For a cleaner PoC narrative, you can keep only the triage-driven workflow and disable the others. +## Protecting Your Tokens on a Public Repo + +Since this repo is public, anyone could post `/oc` comments and trigger your workflow, consuming your API tokens. + +The workflow guards against this by checking that the commenter is the **repository owner** before the runner even starts: + +```yaml +if: github.event.comment.user.login == github.repository_owner && ... +``` + +This check happens entirely within GitHub — no runner is provisioned and no tokens are spent if the condition is false. Anyone else posting `/oc` commands will simply have their comment ignored. + +If you later want to extend access to specific collaborators, you can use: + +```yaml +github.event.comment.author_association == 'COLLABORATOR' || +github.event.comment.author_association == 'MEMBER' || +github.event.comment.author_association == 'OWNER' +``` + +`author_association` values GitHub provides: + +| Value | Meaning | +|-------|---------| +| `OWNER` | Repository owner | +| `MEMBER` | Org member with write access | +| `COLLABORATOR` | Explicitly added collaborator | +| `CONTRIBUTOR` | Has merged a PR before | +| `FIRST_TIME_CONTRIBUTOR` | First merged PR | +| `FIRST_TIMER` | First ever PR on GitHub | +| `NONE` | No relationship | + From 3e9a99464c9a8e25323a22f88eb45163077ff5e0 Mon Sep 17 00:00:00 2001 From: Fernando Pinto Date: Sun, 3 May 2026 19:19:49 +0100 Subject: [PATCH 13/15] wip Co-authored-by: Copilot --- .opencode/agents/issue-implementer.md | 1 - .opencode/agents/issue-planner.md | 1 - .opencode/agents/pr-review.md | 1 - .opencode/agents/triage.md | 5 ++++- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.opencode/agents/issue-implementer.md b/.opencode/agents/issue-implementer.md index e4c72df8..7701e208 100644 --- a/.opencode/agents/issue-implementer.md +++ b/.opencode/agents/issue-implementer.md @@ -1,4 +1,3 @@ -# .opencode/agents/issue-implementer.md --- description: Implements a planned GitHub issue by reading the latest OpenCode Plan issue comment. OpenCode GitHub Action handles branch, push, and PR creation. mode: subagent diff --git a/.opencode/agents/issue-planner.md b/.opencode/agents/issue-planner.md index 456cc00f..522a9919 100644 --- a/.opencode/agents/issue-planner.md +++ b/.opencode/agents/issue-planner.md @@ -1,4 +1,3 @@ -# .opencode/agents/issue-planner.md --- description: Creates an implementation plan for a GitHub issue without modifying files. mode: subagent diff --git a/.opencode/agents/pr-review.md b/.opencode/agents/pr-review.md index 204bb782..8fa53327 100644 --- a/.opencode/agents/pr-review.md +++ b/.opencode/agents/pr-review.md @@ -1,4 +1,3 @@ -# .opencode/agents/pr-review.md --- description: Reviews pull requests for correctness, security, reliability, maintainability, and tests. mode: subagent diff --git a/.opencode/agents/triage.md b/.opencode/agents/triage.md index 21245ef1..d7cfde85 100644 --- a/.opencode/agents/triage.md +++ b/.opencode/agents/triage.md @@ -1,4 +1,3 @@ -# .opencode/agents/triage.md --- description: Reads the triggering comment command and delegates to the correct specialist agent — issue-planner, issue-implementer, or pr-review. mode: primary @@ -37,6 +36,8 @@ Read the comment body provided in the prompt. Match it to one of the commands be | `/oc implement`, `/opencode implement`, `/oc implment`, or `/opencode implment` | `issue-implementer` (issues: follow plan; PRs: fix review comments) | | `/oc review` or `/opencode review` | `pr-review` (PRs only) | +Command suffixes are allowed. Examples like `/oc plan again` and `/opencode implement please` must still map to the same base command. + ## Behavior 1. Identify which command is present in the comment. @@ -54,6 +55,8 @@ Read the comment body provided in the prompt. Match it to one of the commands be - If the comment contains a review command, delegate to `pr-review`. - If none match, do not delegate and post help text. +When calling Task, set the subagent type to exactly one of: `issue-planner`, `issue-implementer`, `pr-review`. Never use any other subagent type. + If the comment does not match any known command, reply to the comment explaining the available commands: - `/oc plan` — create an implementation plan for this issue - `/oc implement` — implement the existing plan for this issue From ef3a3d1f88bcce30676d45b0a76dc5a81669e9d8 Mon Sep 17 00:00:00 2001 From: Fernando Pinto Date: Sun, 3 May 2026 19:22:39 +0100 Subject: [PATCH 14/15] wip Co-authored-by: Copilot --- .opencode/agents/triage.md | 32 ++++++++++++++++++++++---------- README.md | 10 ++++++++++ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/.opencode/agents/triage.md b/.opencode/agents/triage.md index d7cfde85..8121d843 100644 --- a/.opencode/agents/triage.md +++ b/.opencode/agents/triage.md @@ -28,19 +28,21 @@ You are a triage agent. Your only job is to read the comment that triggered this ## Routing rules -Read the comment body provided in the prompt. Match it to one of the commands below (case-insensitive, anywhere in the text): +Read the comment body provided in the prompt. -| Command | Delegate to agent | +Only route comments that include `/oc` or `/opencode` (case-insensitive). After that prefix, interpret the remaining text naturally and map intent to one of the three routes below. + +| Intent (examples, not exhaustive) | Delegate to agent | |---------|------------------| -| `/oc plan` or `/opencode plan` | `issue-planner` (issues only) | -| `/oc implement`, `/opencode implement`, `/oc implment`, or `/opencode implment` | `issue-implementer` (issues: follow plan; PRs: fix review comments) | -| `/oc review` or `/opencode review` | `pr-review` (PRs only) | +| planning: `plan`, `replan`, `design`, `break this down`, `what is the approach`, `plan again` | `issue-planner` (issues only) | +| implementation: `implement`, `implment`, `build this`, `apply the plan`, `fix this`, `address comments` | `issue-implementer` (issues: follow plan; PRs: fix review comments) | +| review: `review`, `audit`, `check this PR`, `code review`, `look for issues` | `pr-review` (PRs only) | -Command suffixes are allowed. Examples like `/oc plan again` and `/opencode implement please` must still map to the same base command. +Command suffixes are allowed. Examples like `/oc can you plan this again?` and `/opencode please address all PR comments` must map to the matching intent. ## Behavior -1. Identify which command is present in the comment. +1. Identify which intent is present in the comment text after `/oc` or `/opencode`. 2. Select the matching subagent from the routing table above. 3. Use the **Task tool** to invoke only that exact subagent name: `issue-planner`, `issue-implementer`, or `pr-review`. 4. Never invoke built-in subagents such as `general` or `explore` for command routing. @@ -50,11 +52,16 @@ Command suffixes are allowed. Examples like `/oc plan again` and `/opencode impl ## Strict command mapping -- If the comment contains a plan command, delegate to `issue-planner`. -- If the comment contains an implement command (`implement` or `implment`), delegate to `issue-implementer`. -- If the comment contains a review command, delegate to `pr-review`. +- If the comment expresses planning intent, delegate to `issue-planner`. +- If the comment expresses implementation intent (`implement` or `implment` included), delegate to `issue-implementer`. +- If the comment expresses review intent, delegate to `pr-review`. - If none match, do not delegate and post help text. +If multiple intents appear, use this precedence order: +1. `review` +2. `implement` +3. `plan` + When calling Task, set the subagent type to exactly one of: `issue-planner`, `issue-implementer`, `pr-review`. Never use any other subagent type. If the comment does not match any known command, reply to the comment explaining the available commands: @@ -62,4 +69,9 @@ If the comment does not match any known command, reply to the comment explaining - `/oc implement` — implement the existing plan for this issue - `/oc review` — review this pull request +Also mention that natural language is supported after the prefix, for example: +- `/oc can you plan this issue?` +- `/oc please implement this` +- `/oc can you review this PR?` + Do not guess or infer intent beyond what is explicitly stated in the comment. diff --git a/README.md b/README.md index 0ba49c77..6786fea6 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,16 @@ Common typo supported in triage mapping: /opencode implment ``` +Natural language is also supported after the prefix: + +```text +/oc can you plan this issue from scratch? +/oc please implement this +/oc can you review this PR for bugs? +``` + +The prefix is still required so the workflow can detect intent safely. + ## Behavior by Command ### 1) Plan From d0e7580b827a9d4b2839204594efadeb416395c1 Mon Sep 17 00:00:00 2001 From: "opencode[bot]" Date: Sun, 3 May 2026 20:17:26 +0000 Subject: [PATCH 15/15] Implemented #6 plan: 3 fixes, 30 tests Co-authored-by: fnandop --- .env.example | 18 +++ .gitignore | 3 + package.json | 23 ++++ src/__tests__/auth.test.js | 227 ++++++++++++++++++++++++++++++++++ src/__tests__/weather.test.js | 83 +++++++++++++ src/index.js | 96 ++++++++++++++ src/routes/auth.js | 53 ++++++++ src/routes/weather.js | 25 ++++ src/services/auth.js | 86 +++++++++++++ src/services/weather.js | 79 ++++++++++++ 10 files changed, 693 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 package.json create mode 100644 src/__tests__/auth.test.js create mode 100644 src/__tests__/weather.test.js create mode 100644 src/index.js create mode 100644 src/routes/auth.js create mode 100644 src/routes/weather.js create mode 100644 src/services/auth.js create mode 100644 src/services/weather.js diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..1d8af53e --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Server configuration +PORT=3000 + +# Environment (development, production, test) +NODE_ENV=development + +# OpenWeatherMap API key (optional - uses mock data if not set) +OPENWEATHERMAP_API_KEY= + +# CORS allowed origins (comma-separated) +CORS_ORIGINS=http://localhost:3000 + +# JWT configuration +JWT_SECRET=your-super-secret-jwt-key-change-in-production +JWT_EXPIRES_IN=1h + +# Demo user password hash (optional - defaults to bcrypt hash of 'password123') +DEMO_PASSWORD_HASH= diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..27a2084e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env +coverage/ diff --git a/package.json b/package.json new file mode 100644 index 00000000..004daeeb --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "opencode-sdlc", + "version": "1.0.0", + "description": "Weather API with JWT authentication and user management", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "test": "node --experimental-vm-modules node_modules/.bin/jest --coverage" + }, + "dependencies": { + "axios": "^1.6.0", + "bcryptjs": "^3.0.3", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "express-rate-limit": "^8.4.1", + "jsonwebtoken": "^9.0.3" + }, + "devDependencies": { + "jest": "^29.7.0", + "supertest": "^7.2.2" + } +} diff --git a/src/__tests__/auth.test.js b/src/__tests__/auth.test.js new file mode 100644 index 00000000..8e8ae948 --- /dev/null +++ b/src/__tests__/auth.test.js @@ -0,0 +1,227 @@ +const request = require('supertest'); +const { app } = require('../index'); +const { validateUser, generateToken, verifyToken, JWT_SECRET } = require('../services/auth'); +const jwt = require('jsonwebtoken'); +const bcrypt = require('bcryptjs'); + +describe('Auth Service', () => { + describe('validateUser', () => { + test('returns user object with valid credentials', () => { + const user = validateUser('demo', 'password123'); + expect(user).not.toBeNull(); + expect(user.username).toBe('demo'); + expect(user.id).toBe(1); + expect(user.role).toBe('user'); + expect(user.passwordHash).toBeUndefined(); + }); + + test('returns null with invalid username', () => { + const user = validateUser('nonexistent', 'password123'); + expect(user).toBeNull(); + }); + + test('returns null with invalid password', () => { + const user = validateUser('demo', 'wrongpassword'); + expect(user).toBeNull(); + }); + + test('returns null with missing username', () => { + const user = validateUser(null, 'password123'); + expect(user).toBeNull(); + }); + + test('returns null with missing password', () => { + const user = validateUser('demo', null); + expect(user).toBeNull(); + }); + + test('returns null with empty string credentials', () => { + const user = validateUser('', ''); + expect(user).toBeNull(); + }); + }); + + describe('generateToken', () => { + test('returns a valid JWT token', () => { + const user = { id: 1, username: 'demo', role: 'user' }; + const token = generateToken(user); + expect(typeof token).toBe('string'); + expect(token.split('.')).toHaveLength(3); // JWT has 3 parts + }); + + test('token contains correct payload', () => { + const user = { id: 1, username: 'demo', role: 'user' }; + const token = generateToken(user); + // Use the imported JWT_SECRET instead of duplicating the fallback value + const decoded = jwt.verify(token, JWT_SECRET); + expect(decoded.sub).toBe(1); + expect(decoded.username).toBe('demo'); + expect(decoded.role).toBe('user'); + }); + + test('token expires after configured time', () => { + const user = { id: 1, username: 'demo', role: 'user' }; + const token = generateToken(user); + const decoded = jwt.decode(token); + + // Token should have an exp (expiration) field + expect(decoded).toHaveProperty('exp'); + expect(decoded).toHaveProperty('iat'); + + // exp should be greater than iat + expect(decoded.exp).toBeGreaterThan(decoded.iat); + }); + }); + + describe('verifyToken', () => { + test('verifies valid token correctly', () => { + const user = { id: 1, username: 'demo', role: 'user' }; + const token = generateToken(user); + const decoded = verifyToken(token); + expect(decoded).not.toBeNull(); + expect(decoded.username).toBe('demo'); + }); + + test('returns null for invalid token', () => { + const decoded = verifyToken('invalid-token'); + expect(decoded).toBeNull(); + }); + + test('returns null for expired token', () => { + // Create a token that expired 1 second ago + const payload = { sub: 1, username: 'demo', role: 'user', iat: Math.floor(Date.now() / 1000) - 2, exp: Math.floor(Date.now() / 1000) - 1 }; + const expiredToken = jwt.sign(payload, JWT_SECRET); + const decoded = verifyToken(expiredToken); + expect(decoded).toBeNull(); + }); + + test('token expires after JWT_EXPIRES_IN duration elapses naturally', () => { + const user = { id: 1, username: 'demo', role: 'user' }; + + // Save original JWT_EXPIRES_IN + const originalExpiresIn = process.env.JWT_EXPIRES_IN; + + // Set a very short expiry time for testing + process.env.JWT_EXPIRES_IN = '1s'; + + // Generate token with short expiry + const token = generateToken(user); + + // Verify token is valid immediately after generation + expect(verifyToken(token)).not.toBeNull(); + + // Use fake timers to advance past the expiry time + jest.useFakeTimers(); + // Advance time by 2 seconds (past the 1s expiry) + jest.setSystemTime(Date.now() + 2000); + + // Token should now be expired + const result = verifyToken(token); + expect(result).toBeNull(); + + // Restore real timers and original env var + jest.useRealTimers(); + process.env.JWT_EXPIRES_IN = originalExpiresIn; + }); + }); +}); + +describe('Auth API - POST /api/auth/login', () => { + test('returns 200 and token with valid credentials', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({ username: 'demo', password: 'password123' }); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('message', 'Login successful'); + expect(response.body).toHaveProperty('token'); + expect(response.body).toHaveProperty('user'); + expect(response.body.user).toHaveProperty('username', 'demo'); + expect(response.body.user).toHaveProperty('id', 1); + expect(response.body.user).toHaveProperty('role', 'user'); + expect(response.body.user).not.toHaveProperty('passwordHash'); + + // Verify token is valid JWT using the imported JWT_SECRET + const decoded = jwt.verify(response.body.token, JWT_SECRET); + expect(decoded.username).toBe('demo'); + }); + + test('returns 401 with invalid username', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({ username: 'nonexistent', password: 'password123' }); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('error', 'Invalid credentials'); + }); + + test('returns 401 with invalid password', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({ username: 'demo', password: 'wrongpassword' }); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('error', 'Invalid credentials'); + }); + + test('returns 400 with missing username', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({ password: 'password123' }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + expect(response.body.error).toContain('Missing required fields'); + }); + + test('returns 400 with missing password', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({ username: 'demo' }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + expect(response.body.error).toContain('Missing required fields'); + }); + + test('returns 400 with missing both username and password', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({}); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + expect(response.body.error).toContain('Missing required fields'); + }); + + test('returns 400 with empty string credentials', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({ username: '', password: '' }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + expect(response.body.error).toContain('Missing required fields'); + }); + + test('generated token has correct structure', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({ username: 'demo', password: 'password123' }); + + const token = response.body.token; + expect(token).toBeTruthy(); + + // JWT should have 3 parts separated by dots + const parts = token.split('.'); + expect(parts).toHaveLength(3); + + // Decode payload (second part) + const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); + expect(payload).toHaveProperty('sub', 1); + expect(payload).toHaveProperty('username', 'demo'); + expect(payload).toHaveProperty('role', 'user'); + expect(payload).toHaveProperty('iat'); + expect(payload).toHaveProperty('exp'); + }); +}); diff --git a/src/__tests__/weather.test.js b/src/__tests__/weather.test.js new file mode 100644 index 00000000..25042004 --- /dev/null +++ b/src/__tests__/weather.test.js @@ -0,0 +1,83 @@ +const request = require('supertest'); +const { app } = require('../index'); +const { fetchWeather, clearCache, getMockWeather } = require('../services/weather'); + +describe('Weather Service', () => { + beforeEach(() => { + clearCache(); + delete process.env.OPENWEATHERMAP_API_KEY; + }); + + describe('getMockWeather', () => { + test('returns default mock data for unknown location', () => { + const result = getMockWeather('UnknownCity'); + expect(result).toHaveProperty('location', 'UnknownCity'); + expect(result).toHaveProperty('temperature'); + expect(result).toHaveProperty('description'); + expect(result).toHaveProperty('humidity'); + expect(result).toHaveProperty('timestamp'); + }); + + test('returns specific data for London', () => { + const result = getMockWeather('London'); + expect(result.temperature).toBe(15); + expect(result.description).toBe('Cloudy'); + }); + + test('returns specific data for Tokyo', () => { + const result = getMockWeather('Tokyo'); + expect(result.temperature).toBe(25); + expect(result.description).toBe('Partly cloudy'); + }); + }); + + describe('fetchWeather', () => { + test('returns mock data when no API key is set', async () => { + const result = await fetchWeather('London'); + expect(result.location).toBe('London'); + expect(result.temperature).toBe(15); + }); + + test('caches results', async () => { + const result1 = await fetchWeather('Paris'); + const result2 = await fetchWeather('Paris'); + expect(result1).toEqual(result2); + }); + }); +}); + +describe('Weather API', () => { + beforeEach(() => { + clearCache(); + delete process.env.OPENWEATHERMAP_API_KEY; + }); + + test('GET /api/weather returns 400 when location is missing', async () => { + const response = await request(app).get('/api/weather'); + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + }); + + test('GET /api/weather?location=London returns weather data', async () => { + const response = await request(app).get('/api/weather?location=London'); + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('location', 'London'); + expect(response.body).toHaveProperty('temperature', 15); + expect(response.body).toHaveProperty('description', 'Cloudy'); + expect(response.body).toHaveProperty('humidity'); + expect(response.body).toHaveProperty('timestamp'); + }); + + test('GET /health returns ok status', async () => { + const response = await request(app).get('/health'); + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('status', 'ok'); + expect(response.body).toHaveProperty('timestamp'); + }); + + test('GET /api/weather with unknown location returns default mock', async () => { + const response = await request(app).get('/api/weather?location=UnknownPlace'); + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('location', 'UnknownPlace'); + }); +}); diff --git a/src/index.js b/src/index.js new file mode 100644 index 00000000..7597cb52 --- /dev/null +++ b/src/index.js @@ -0,0 +1,96 @@ +require('dotenv').config(); +const express = require('express'); +const cors = require('cors'); +const weatherRoutes = require('./routes/weather'); +const authRoutes = require('./routes/auth'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// Configure CORS with allowed origins from environment variable +const allowedOrigins = process.env.CORS_ORIGINS + ? process.env.CORS_ORIGINS.split(',').map(origin => origin.trim()) + : ['http://localhost:3000']; + +app.use(cors({ + origin: allowedOrigins, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'] +})); +app.use(express.json()); + +app.get('/health', (req, res) => { + res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +app.use('/api/weather', weatherRoutes); +app.use('/api/auth', authRoutes); + +// Express error-handling middleware (must be after all routes) +app.use((err, req, res, _next) => { + console.error('Unhandled error:', err.message); + res.status(500).json({ + error: 'Internal server error' + }); +}); + +let server; + +function start() { + server = app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`); + }); + + // Handle server-level errors (e.g., port already in use) + server.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + console.error(`Port ${PORT} is already in use`); + } else { + console.error('Server error:', err.message); + } + process.exit(1); + }); + + return server; +} + +function stop() { + return new Promise((resolve) => { + if (server) { + // Connection draining: forcefully close after timeout + const DRAIN_TIMEOUT_MS = 10000; + const forceTimeout = setTimeout(() => { + console.log('Forcing shutdown after connection drain timeout'); + process.exit(1); + }, DRAIN_TIMEOUT_MS); + forceTimeout.unref(); // Don't keep process alive just for this timeout + + // Stop accepting new connections and drain existing ones + server.close(() => { + clearTimeout(forceTimeout); // Clear the force timeout on successful close + console.log('Server stopped'); + resolve(); + }); + } else { + resolve(); + } + }); +} + +process.on('SIGTERM', async () => { + console.log('SIGTERM received, shutting down gracefully'); + await stop(); + process.exit(0); +}); + +process.on('SIGINT', async () => { + console.log('SIGINT received, shutting down gracefully'); + await stop(); + process.exit(0); +}); + +if (require.main === module) { + start(); +} + +module.exports = { app, start, stop }; diff --git a/src/routes/auth.js b/src/routes/auth.js new file mode 100644 index 00000000..a76b2f1b --- /dev/null +++ b/src/routes/auth.js @@ -0,0 +1,53 @@ +const express = require('express'); +const rateLimit = require('express-rate-limit'); +const { validateUser, generateToken } = require('../services/auth'); + +const router = express.Router(); + +// Rate limiter for login endpoint to prevent brute-force attacks +const loginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 20, // Limit each IP to 20 login attempts per windowMs + standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers + legacyHeaders: false, // Disable the `X-RateLimit-*` headers + message: { error: 'Too many login attempts, please try again later' } +}); + +/** + * POST /api/auth/login + * Authenticate user and return JWT token + */ +router.post('/login', loginLimiter, (req, res) => { + const { username, password } = req.body; + + // Validate input + if (!username || !password) { + return res.status(400).json({ + error: 'Missing required fields: username and password are required' + }); + } + + // Validate credentials + const user = validateUser(username, password); + + if (!user) { + return res.status(401).json({ + error: 'Invalid credentials' + }); + } + + // Generate JWT token + const token = generateToken(user); + + return res.status(200).json({ + message: 'Login successful', + token, + user: { + id: user.id, + username: user.username, + role: user.role + } + }); +}); + +module.exports = router; diff --git a/src/routes/weather.js b/src/routes/weather.js new file mode 100644 index 00000000..476e4f8b --- /dev/null +++ b/src/routes/weather.js @@ -0,0 +1,25 @@ +const express = require('express'); +const router = express.Router(); +const { fetchWeather } = require('../services/weather'); + +router.get('/', async (req, res) => { + const { location } = req.query; + + if (!location) { + return res.status(400).json({ + error: 'Missing required query parameter: location' + }); + } + + try { + const weather = await fetchWeather(location); + res.status(200).json(weather); + } catch (error) { + if (error.message.includes('not found')) { + return res.status(404).json({ error: error.message }); + } + res.status(500).json({ error: 'Internal server error' }); + } +}); + +module.exports = router; diff --git a/src/services/auth.js b/src/services/auth.js new file mode 100644 index 00000000..1f27e663 --- /dev/null +++ b/src/services/auth.js @@ -0,0 +1,86 @@ +const jwt = require('jsonwebtoken'); +const bcrypt = require('bcryptjs'); + +// Throw an error if JWT_SECRET is missing in non-development/test environments +const isDevOrTest = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test' || !process.env.NODE_ENV; +const JWT_SECRET = process.env.JWT_SECRET || (isDevOrTest + ? 'default-dev-secret-change-in-production' + : (() => { throw new Error('JWT_SECRET environment variable is required in production'); })()); +const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1h'; + +// Use env var for demo password, falling back to a pre-computed static hash of 'password123' +// Pre-computed bcrypt hash of 'password123' with salt rounds 10 +// Generated once and stored statically so the hash is stable across server restarts +const DEMO_PASSWORD_HASH = process.env.DEMO_PASSWORD_HASH || '$2a$10$e0MYzXyIkJKMIlJjwSCLPeOy0fLhT5K6yS0p7qVlM8aHqE1zN8gGi'; + +// In-memory user store (MVP - replace with database in production) +const users = [ + { + id: 1, + username: 'demo', + passwordHash: DEMO_PASSWORD_HASH, + role: 'user' + } +]; + +// Dummy hash used for constant-time comparison when user is not found +// This is a pre-computed bcrypt hash of 'dummy-password-for-timing' with salt rounds 10 +const DUMMY_HASH = bcrypt.hashSync('dummy-password-for-timing', 10); + +/** + * Validate user credentials + * @param {string} username + * @param {string} password + * @returns {object|null} User object without password hash, or null if invalid + */ +function validateUser(username, password) { + if (!username || !password) { + return null; + } + + const user = users.find(u => u.username === username); + + // Always run bcrypt.compare to prevent timing side-channel attacks. + // If user is not found, compare against a dummy hash so the response time + // is indistinguishable from a failed password check. + const passwordHash = user ? user.passwordHash : DUMMY_HASH; + const isValid = bcrypt.compareSync(password, passwordHash); + + if (!user || !isValid) { + return null; + } + + // Return user without password hash + const { passwordHash: _passwordHash, ...userWithoutPassword } = user; + return userWithoutPassword; +} + +/** + * Generate JWT token for authenticated user + * @param {object} user - User object (should not contain password hash) + * @returns {string} JWT token + */ +function generateToken(user) { + const payload = { + sub: user.id, + username: user.username, + role: user.role + }; + + return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN }); +} + +/** + * Verify JWT token + * @param {string} token + * @returns {object|null} Decoded token payload or null if invalid + */ +function verifyToken(token) { + try { + return jwt.verify(token, JWT_SECRET); + } catch (error) { + return null; + } +} + +module.exports = { validateUser, generateToken, verifyToken, JWT_SECRET }; diff --git a/src/services/weather.js b/src/services/weather.js new file mode 100644 index 00000000..122cff5c --- /dev/null +++ b/src/services/weather.js @@ -0,0 +1,79 @@ +require('dotenv').config(); +const axios = require('axios'); + +const CACHE_DURATION = 10 * 60 * 1000; // 10 minutes +const cache = new Map(); + +function getMockWeather(location) { + const normalizedLocation = location.toLowerCase(); + const mockData = { + location: location, + temperature: 22, + description: 'Clear sky', + humidity: 65, + timestamp: new Date().toISOString() + }; + + const variations = { + 'london': { temperature: 15, description: 'Cloudy', humidity: 80 }, + 'tokyo': { temperature: 25, description: 'Partly cloudy', humidity: 70 }, + 'new york': { temperature: 18, description: 'Rainy', humidity: 85 }, + 'sydney': { temperature: 28, description: 'Sunny', humidity: 55 }, + 'paris': { temperature: 20, description: 'Light breeze', humidity: 60 } + }; + + if (variations[normalizedLocation]) { + return { ...mockData, ...variations[normalizedLocation] }; + } + + return mockData; +} + +async function fetchWeather(location) { + const cacheKey = location.toLowerCase(); + const cached = cache.get(cacheKey); + + if (cached && Date.now() - cached.timestamp < CACHE_DURATION) { + return cached.data; + } + + const apiKey = process.env.OPENWEATHERMAP_API_KEY; + + let weatherData; + + if (apiKey) { + try { + const response = await axios.get('https://api.openweathermap.org/data/2.5/weather', { + params: { + q: location, + appid: apiKey, + units: 'metric' + } + }); + + weatherData = { + location: response.data.name, + temperature: response.data.main.temp, + description: response.data.weather[0].description, + humidity: response.data.main.humidity, + timestamp: new Date().toISOString() + }; + } catch (error) { + if (error.response && error.response.status === 404) { + throw new Error(`Location '${location}' not found`); + } + weatherData = getMockWeather(location); + } + } else { + weatherData = getMockWeather(location); + } + + cache.set(cacheKey, { data: weatherData, timestamp: Date.now() }); + return weatherData; +} + +function clearCache() { + cache.clear(); +} + +module.exports = { fetchWeather, clearCache, getMockWeather };