diff --git a/.claude/hookify.protect-archive-branches.local.md b/.claude/hookify.protect-archive-branches.local.md deleted file mode 100644 index 2d30493ab6..0000000000 --- a/.claude/hookify.protect-archive-branches.local.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -name: protect-archive-branches -enabled: true -event: bash -action: block -conditions: - - field: command - operator: regex_match - pattern: git\s+(rebase|branch\s+-D|branch\s+--delete\s+(-f|--force)|push\s+(.*\s+)?(-f|--force)|reset\s+--hard).*\b(dev/[\w.-]*(backup|snapshot)[\w.-]*|test-data-backup-2026-04-30)\b - - field: command - operator: not_contains - pattern: CONFIRM_DESTROY=1 ---- - -🛑 **Refusing to run destructive git op on a protected archive branch.** - -The branch you targeted matches the archive pattern (`dev/*-backup-*`, `dev/*-snapshot-*`, or `test-data-backup-2026-04-30`). These branches are intentional historical snapshots — rebasing, force-pushing, or hard-resetting them destroys irrecoverable state. - -**Before overriding, run the 3-question check** (from `~/.claude/projects/-Users-h2oslabs-Workspace-hackforger/memory/feedback_branch_rebase_check.md`): - -1. Is the branch pushed to origin? (if not, no one else has a copy) -2. Is the branch / its files referenced by paths on the default branch? (e.g. `git grep v0.1-dev/hackforger`) -3. Are the unique commits "snapshot" / "backup" / "preserve" themed? - -If any answer is YES → leave it alone, do not destroy. - -If you've checked all three and the user has explicitly confirmed they want to destroy this branch (rare): - -```bash -CONFIRM_DESTROY=1 -``` - -The `CONFIRM_DESTROY=1` env-var prefix is the explicit override; this hook will not block it. diff --git a/.claude/hookify.protect-worktrees.local.md b/.claude/hookify.protect-worktrees.local.md deleted file mode 100644 index 32f0b53e17..0000000000 --- a/.claude/hookify.protect-worktrees.local.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -name: protect-worktrees -enabled: true -event: bash -action: block -conditions: - - field: command - operator: regex_match - pattern: git\s+worktree\s+remove\s+(.*\s+)?\.claude/worktrees/ - - field: command - operator: not_contains - pattern: CONFIRM_DESTROY=1 ---- - -🛑 **Refusing to remove a worktree under `.claude/worktrees/`.** - -The worktrees in `.claude/worktrees/` are load-bearing: - -- **`.claude/worktrees/dev/`** — backs the `dev/test-data-backup-2026-04-30` archive branch. The default branch's `docs/notes/pg-migration-pitfalls.md` references files at `.claude/worktrees/dev/data-snapshot/RESTORE.md`. Removing the worktree breaks that reference. -- Other worktrees may back active dev branches you didn't realize were in use. - -**Before overriding**, verify: - -1. The worktree's branch isn't referenced from the default branch (`git grep v0.1-dev/hackforger`) -2. No file in the worktree is unique (compare against the branch HEAD) -3. The user has explicitly asked to remove it (not just "clean up") - -If all three pass, override with: - -```bash -CONFIRM_DESTROY=1 git worktree remove .claude/worktrees/ -``` diff --git a/.claude/projects/-Users-h2oslabs-Workspace-hackforger/memory/feedback_e2e_web_mandatory.md b/.claude/projects/-Users-h2oslabs-Workspace-hackforger/memory/feedback_e2e_web_mandatory.md deleted file mode 100644 index 14f6b75676..0000000000 --- a/.claude/projects/-Users-h2oslabs-Workspace-hackforger/memory/feedback_e2e_web_mandatory.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -name: E2E must be web-only with screenshots -description: E2E tests must use agent-browser web UI operations, never unit/API tests as substitute; key checkpoints require screenshots as evidence -type: feedback ---- - -E2E tests MUST use web interface operations via agent-browser. Unit tests and API tests cannot substitute for web UI testing in E2E. - -**Why:** The user requires proof that the full user-facing flow works end-to-end through the actual browser interface, not just that the backend API returns correct results. - -**How to apply:** -- Every E2E prompt must explicitly state: "Web 端测试是必选项,不可使用单元测试或 API 测试替代 Web 界面操作" -- Key verification points must include `agent-browser screenshot` commands -- Screenshots saved to `docs/tests/e2e/screenshots/` and referenced in E2E reports as evidence -- Use `agent-browser --session` for multi-user flows, `eval --stdin` for session-auth POST calls diff --git a/.claude/settings.json b/.claude/settings.json index e1cfa51993..bcf2d8c1ca 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,9 +4,7 @@ "Bash(bash scripts/restart-gitea.sh)", "Bash(bash scripts/restart-gitea.sh *)", "Bash(./scripts/restart-gitea.sh)", - "Bash(./scripts/restart-gitea.sh *)", - "Bash(/Users/h2oslabs/Workspace/hackforger/scripts/restart-gitea.sh)", - "Bash(/Users/h2oslabs/Workspace/hackforger/scripts/restart-gitea.sh *)" + "Bash(./scripts/restart-gitea.sh *)" ] }, "hooks": { @@ -17,14 +15,6 @@ { "type": "command", "command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -qE '\\btea\\s+(issue|pr|repo|release|login)'; then echo 'BLOCKED: Please use `gh` instead of `tea`. tea is the Forgejo/Codeberg CLI; we develop on GitHub. Equivalent: gh issue / gh pr / gh repo.' >&2; exit 2; fi" - }, - { - "type": "command", - "command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -qE 'codeberg\\.org/(Synnovator|HackForge)'; then echo 'BLOCKED: The project is hosted on GitHub, not Codeberg. Correct address: github.com/HackForger/hackforger' >&2; exit 2; fi" - }, - { - "type": "command", - "command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -qE 'hackforge\\.inside\\.h2os\\.cloud'; then echo 'BLOCKED: The internal instance address is hackforger.inside.h2os.cloud (with r). Please update the URL.' >&2; exit 2; fi" } ] }, diff --git a/.github/ISSUE_TEMPLATE/1-bug.yml b/.github/ISSUE_TEMPLATE/1-bug.yml index 288ac92337..66a92d5eb5 100644 --- a/.github/ISSUE_TEMPLATE/1-bug.yml +++ b/.github/ISSUE_TEMPLATE/1-bug.yml @@ -14,8 +14,8 @@ body: id: page_url attributes: label: 出问题的页面 URL - description: 完整地址。如果是登录后才能看到的页面,**也直接贴 URL**,我们能猜到要登录哪个账户。 - placeholder: "https://www.synnovator.com/hackathon/opc-2026-xxxxx" + description: 请使用中立测试实例或脱敏路径;不要公开生产域名、内部地址、查询参数中的令牌或客户标识。 + placeholder: "https://hackforger.example.invalid/hackathon/example-event" validations: required: true @@ -23,8 +23,8 @@ body: id: account attributes: label: 你用什么账号操作的? - description: 用户名(不要填密码)。如果是匿名访问,写 `匿名`。 - placeholder: "linyilun" + description: 只填写可公开的测试用户名(绝不填写密码、真实生产账号或个人身份);匿名访问写 `匿名`。 + placeholder: "example-user" validations: required: true @@ -35,10 +35,10 @@ body: description: | 从打开浏览器开始一步一步写。最理想是别人按你这个步骤一定能看到同样的现象。 placeholder: | - 1. 用 linyilun 账号登录 - 2. 打开 https://www.synnovator.com/hackathon/opc-2026-shuzhi-w1 + 1. 用测试账号登录 + 2. 打开对应活动页面 3. 点页面右下角"报名"区 - 4. 在"选择仓库"下拉框里找 H2OSLabs/page.h2oslabs.com + 4. 在"选择仓库"下拉框里查找有权限的组织仓库 value: | 1. 2. @@ -51,7 +51,7 @@ body: attributes: label: 期望看到 / 期望发生的事 description: 一两句话即可。 - placeholder: 下拉里应该列出我所属的 H2OSLabs 组织的所有可写仓库,包括 page.h2oslabs.com + placeholder: 下拉里应该列出我所属组织的所有可写仓库 validations: required: true @@ -71,6 +71,7 @@ body: description: | **强烈建议**贴 1-3 张截图(直接拖图到这个框里就会自动上传)。一张图胜过一千字。 如果是涉及多步操作的 bug,请截图关键步骤;如果有错误弹窗 / 红字提示,必须截图。 + 上传前必须遮盖真实用户名、邮箱、令牌、内部域名、客户数据和其他非公开信息。 placeholder: 直接拖拽图片到这里 validations: required: false @@ -93,8 +94,8 @@ body: attributes: label: 你在哪个环境测的? options: - - "线上 https://www.synnovator.com" - - "内网 https://hackforger.inside.h2os.cloud" + - "生产实例" + - "预发布 / 测试实例" - "本地开发实例" - "其他(请在描述里说明)" validations: @@ -116,3 +117,11 @@ body: description: 比如"以前能用,今天突然不行"、"换个账号就好了"、"前几天 #134 改了相关功能可能有影响"等线索都欢迎写在这里。 validations: required: false + + - type: checkboxes + id: public-boundary + attributes: + label: 公开信息确认 + options: + - label: 我已确认本 issue 不含凭据、内部地址、真实生产账号、客户数据或未脱敏截图。 + required: true diff --git a/.github/ISSUE_TEMPLATE/2-feature.yml b/.github/ISSUE_TEMPLATE/2-feature.yml index 4007622d37..889b5295e3 100644 --- a/.github/ISSUE_TEMPLATE/2-feature.yml +++ b/.github/ISSUE_TEMPLATE/2-feature.yml @@ -8,6 +8,7 @@ body: value: | > 提需求前先想想:是 **现有功能不好用**(→ 用 Bug 模板更合适),还是 **完全新功能**(→ 用这个模板)? > 如果是想让 HackForger 多支持一种使用场景,欢迎在下面描述清楚。 + > 本仓库公开且业务中立;请用脱敏示例,不要提交客户内容、内部拓扑、真实账号或凭据。 - type: textarea id: problem @@ -47,3 +48,11 @@ body: - "只是建议 — 觉得这样会更好" validations: required: true + + - type: checkboxes + id: public-boundary + attributes: + label: 公开信息确认 + options: + - label: 我已确认本需求使用中立示例,不含凭据、内部地址、真实生产账号或客户私有内容。 + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index d07f0be8f6..60120c728e 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -4,5 +4,5 @@ contact_links: url: https://github.com/HackForger/hackforger/discussions about: 不确定是 bug 还是需求?想先聊聊?欢迎到 Discussions。 - name: 🚨 安全漏洞 - url: mailto:security@h2os.cloud - about: 安全问题请直接邮件联系,不要在公开 issue 里发。 + url: https://github.com/HackForger/hackforger/security/advisories/new + about: 安全问题请使用私密安全公告,不要在公开 issue 里发。 diff --git a/.gitignore b/.gitignore index b1b8a3a3c2..832fb808a2 100644 --- a/.gitignore +++ b/.gitignore @@ -176,6 +176,7 @@ docs/tests/e2e/*.pdf .claude/projects/ .claude/worktrees/ .claude/scheduled_tasks.lock +/.remember/ # E2E test screenshots (temporary artifacts, hosted on GitHub Releases if needed) tests/screenshots/ diff --git a/CLAUDE.md b/CLAUDE.md index 6f17d7dc4d..0f94d04da0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,150 +1,51 @@ # HackForger Development Guide -## Project Overview -HackForger is a Fork of Forgejo, adding Hackathon, Bounty, Grant, Credits, and Feed modules. -All new code lives in `*/hackforger/` directories, minimizing changes to upstream Forgejo files. +HackForger is a business-neutral Forgejo fork that adds Hackathon, Bounty, Grant, Credits, Feed, and related collaboration modules. -- Upstream: https://codeberg.org/forgejo/forgejo -- Project repo: https://github.com/HackForger/hackforger -- Internal instance: https://hackforger.inside.h2os.cloud -- Primary dev tool: Claude Code +Before changing code, documentation, CI, deployment tooling, templates, locales, or custom assets, read and follow [`skills/hackforger-development/SKILL.md`](skills/hackforger-development/SKILL.md). -## Architecture Rules -- Forgejo uses strict layered architecture: routers -> services -> models -> modules -- Upper layers may only call lower layers, never the reverse -- CRUD data-access functions live in `models/`, not `services/` (matches Forgejo convention: models/issues/issue.go has GetIssueByID) -- Web explore routes must be inside the existing `/explore` group in web.go to inherit `ignExploreSignIn` middleware -- Use pointer types for optional enum filters in ListOptions (nil = no filter, avoids zero-value ambiguity) -- NotifyWatchers only handles repo watchers; HackForger uses custom PublishHackforgerAction for 4 audience types -- New Go package paths: `forgejo.org/models/hackforger/`, `forgejo.org/services/hackforger/`, etc. -- Database: XORM ORM, define Go struct + tags for auto table creation -- Frontend: Go template SSR + partial Vue 3 component enhancement (not SPA) — see [docs/frontend-dev-guide.md](docs/frontend-dev-guide.md) -- **Vue components must call web routes for actions, NOT `/api/v1/` routes** (session cookie auth vs token auth) +## Public repository boundary -## Directory Structure -- `models/hackforger/` -- Data models (16 tables) + CRUD data-access functions (Get/List/Create/Update/Delete) -- `services/hackforger/` -- Business logic only (state machines, transactional operations like Deposit/Redeem) -- `routers/api/v1/hackforger/` -- REST API -- `routers/web/hackforger/` -- Web page routes -- `templates/hackforger/` -- Go HTML templates -- `modules/hackforger/feed/` -- Feed event type definitions -- `web_src/js/features/hackforger/` -- Vue components +- Keep reusable platform code, neutral examples, generic deployment primitives, and their tests in this repository. +- Put branded pages, campaign or customer content, instance configuration, real environment facts, production runbooks, and runtime evidence in the corresponding private business repository. +- Keep credentials in a secret manager or ignored local environment file; never commit them to public or private Git. +- Do not use `git add -f` to bypass ignored private-content paths. +- Run `bash scripts/check-public-repository-boundary.sh` before committing and + again before pushing. CI cannot undo disclosure on an already-public branch. -## Naming Conventions -- Go files: snake_case (hackathon.go, bounty_reward.go) -- Go structs: CamelCase (HackathonSubmission, BountyReward) -- API paths: kebab-case (/api/v1/hackforger/grant-rounds) -- Template files: snake_case (judge_panel.tmpl) -- Vue components: PascalCase (BountyPanel.vue) +## Architecture rules -## Error Handling & i18n -- Service layer returns typed errors (`ErrNoTracks`, `ErrDuplicateRegistration`), NOT `fmt.Errorf("english")` -- Web handlers check error type with `IsErr*()` then call `ctx.Tr()` for user-facing message -- **Never show `err.Error()` to users** without type-checking first -- See [docs/notes/i18n-error-pattern.md](docs/notes/i18n-error-pattern.md) for the pattern +- Preserve the dependency direction `routers -> services -> models -> modules`; never import upward. +- Put CRUD data access in `models/` and business state transitions in `services/`. +- Pass `context.Context` first, return typed errors, and use `db.WithTx` for multi-table operations. +- Publish HackForger feed events for every user-visible state change. +- Keep HackForger additions in the existing `*/hackforger/` extension points where possible. +- Follow Forgejo API, template, locale, and test patterns. +- Use Go template SSR with Vue 3 enhancement; browser actions use session-authenticated web routes rather than token API routes. -## CSRF / Cross-Origin Protection -- Forgejo uses Go's `net/http.CrossOriginProtection` (NOT traditional CSRF tokens) -- **Do NOT add `{{.CsrfTokenHtml}}` or `_csrf` hidden inputs to templates** — they don't exist in Forgejo -- See [docs/notes/cross-origin-protection.md](docs/notes/cross-origin-protection.md) for full explanation and reverse proxy setup +## Error handling and i18n -## Forgejo Actions Token 权限 -- `workflow.Dispatch(ctx, inputGetter, repo, doer)` 中的 **doer 决定 `github.token` 的权限** -- Action token 对自身 repo 有内置写权限 (git push, 创建 PR),但 **merge PR 需要 doer 有 `CanWrite(TypeCode)` 权限** -- **Workflow 中如果需要 merge PR、创建 Release 等管理操作,dispatcher 必须是 repo owner / org admin**,不能用普通用户 (hacker/judge) -- macOS host runner 使用 BSD shell 工具,不要用 GNU 扩展 (`sed \?` 等),改用 POSIX 语法 -- See [docs/notes/forgejo-actions-token-permission.md](docs/notes/forgejo-actions-token-permission.md) for full permission matrix and checklist +- Never surface raw `err.Error()` to users without mapping a typed error. +- Add every user-facing string to both `locale_en-US.ini` and `locale_zh-CN.ini` under the HackForger section. +- Do not add traditional CSRF token fields; Forgejo uses Go cross-origin protection. -## Gitflow & Promotion to Production -- **Process:** [docs/notes/gitflow.md](docs/notes/gitflow.md) — three stages: feat/* → `v0.1-dev/hackforger` (Claude E2E on Mac) → `prod` (admin manual sign-off on Mac) → cloud `https://www.synnovator.com` -- **Both stages of testing happen on the same Mac instance** at `https://hackforger.inside.h2os.cloud`. Branch checked out determines what's tested. -- **Smoke-test report is required** for every user-facing change. File at `docs/tests/e2e/reports/YYYY-MM-DD-.md` with frontmatter `commits: [...]` + `admin_signoff:` populated by admin in stage 2. -- **Pre-deploy gate:** `bash deploy/ecs/preflight.sh` — refuses to ship any commit on `prod` that lacks a matching report. No `--force` override. +## Workflow and verification -## Local Testing -- See [docs/tests/local-testing-guide.md](docs/tests/local-testing-guide.md) for starting HackForger in worktrees, shared database, and common issues -- See [docs/tests/e2e/e2e-lessons-learned.md](docs/tests/e2e/e2e-lessons-learned.md) for common pitfalls (migration mismatch, pr.Issue gotcha, template crashes, Vue auth, feed rendering) -- See [docs/tests/e2e/e2e-testing-guide.md](docs/tests/e2e/e2e-testing-guide.md) for E2E automated testing with agent-browser (localhost:3000, web-first, screenshots) -- See [docs/tests/e2e/templates/](docs/tests/e2e/templates/) for E2E test templates (single feature, regression) -- See [docs/tests/e2e/tasks/](docs/tests/e2e/tasks/) for E2E test tasks (user journey full cycle, feature tests) -- **Key**: always copy `custom/conf/app.ini` from main repo before starting server in a worktree -- **E2E testing**: use `agent-browser` via `http://localhost:3000` (not HTTPS — local proxy blocks Tailscale TLS). Web-first with screenshots in reports. +- Use an isolated worktree for non-trivial changes. +- Feature branches merge into `v0.1-dev/hackforger`; production promotion uses the repository's reviewed release process. +- Store instance-specific release instructions and evidence in the private business repository. +- Run focused tests first, followed by the relevant repository gate bundle. +- User-facing changes require real browser verification; deployment changes require fixture tests, backup proof, and post-deploy runtime evidence. -## Common Commands -- `TAGS="bindata sqlite sqlite_unlock_notify" make backend` -- Compile backend (bindata embeds templates, sqlite enables SQLite3) -- `make frontend` -- Compile frontend (required after JS/Vue changes) -- `go test ./models/hackforger/... -v` -- Run model tests -- `go test ./services/hackforger/... -v` -- Run service tests -- `./gitea web` -- Start server (http://localhost:3000) -- `bash scripts/restart-gitea.sh` -- **After merging a PR**: one-command atomic rebuild + restart of the main instance. Does: build → stop-if-binary-path-matches → start → verify HTTP 200. Refuses to kill port-3000 processes whose binary path isn't the main repo's `gitea`, so it's safe to automate. If you ever see `pre-receive ... No such file or directory` on push, you're in orphan-binary state — see [docs/notes/orphan-binary.md](docs/notes/orphan-binary.md). -- `bash scripts/restart-gitea-test.sh` -- Same flow for the **test instance** (port 3001, `--custom-path /tmp/hackforger-test-custom`, log at `/tmp/gitea-test.log`). Use after editing test app.ini or rebuilding while it's up. -- Restart server manually (fallback): kill old process, remove LevelDB lock (`rm -f data/queues/common/LOCK`), then start +Common commands: -## Important Constraints -- Use `gh` CLI for GitHub operations (not `tea` -- that's for Codeberg/Forgejo) -- Do not modify upstream Forgejo files unless listed in the 11 injection points (see implementation-plan-draft.md section 1.2) -- All state changes must call PublishHackforgerAction to write Feed events -- **i18n**: All user-facing text MUST have both `locale_en-US.ini` and `locale_zh-CN.ini` entries under the `[hackforger]` section. Never add keys to only one locale file. -- Credits Deposit/Redeem must use db.WithTx transactions -- Internal HackForger instance: https://hackforger.inside.h2os.cloud -- API base path: https://hackforger.inside.h2os.cloud/api/v1/hackforger/ +```bash +TAGS="bindata sqlite sqlite_unlock_notify" make backend +make frontend +go test ./models/hackforger/... -v +go test ./services/hackforger/... -v +bash scripts/check-public-repository-boundary.sh +git diff --check +``` -## CI/CD -- Primary CI: GitHub Actions (`.github/workflows/`) -- Self-hosted instance CI: Forgejo Actions (`.forgejo/workflows/`) -- kept for self-deployed HackForger instances -- Do not create Forgejo Actions for the GitHub-hosted repo - -## Environment Variables -- `GITHUB_TOKEN` -- For gh CLI and GitHub API -- `FORGEJO_TOKEN` -- For self-hosted HackForger instance API -- `FORGEJO_URL` -- https://hackforger.inside.h2os.cloud -- `HACKFORGER_ADMIN_PASSWORD` -- `hackforger` admin password on the **production** instance (port 3000) -- `HACKFORGER_TEST_ADMIN_PASSWORD` -- `hackforger` admin password on the **test** instance (port 3001) — different from prod -- `PGPASSWORD` -- Postgres password for the `hackforger` DB user (covers both `hackforger` and `hackforger_test` databases) -- `ECS_SUDO_PASS` -- Cloud ECS `hackforger` user's sudo password (used by `deploy/ecs/redeploy.sh` and `scripts/sync-from-prod.sh` for non-TTY ssh) -- All of the above live in `.env` (gitignored). Source it with `set -a; . .env; set +a` if a tool needs them in the shell. - -## Cloud Production Instance (the live one users hit) -- URL: https://www.synnovator.com (Caddy auto-HTTPS via Let's Encrypt) -- Host: Huawei Cloud ECS at `203.119.115.130` (Ubuntu 24.04, 4c/15GiB) -- SSH: `ssh hackforger@203.119.115.130` (key auth; sudo password in `.env` as `ECS_SUDO_PASS`) -- Stack: native binaries (PG18 / gitea / forgejo-runner / Caddy), all systemd-managed -- Layout: binary at `/opt/hackforger/gitea`, data at `/var/lib/hackforger/{data,custom,pg-backups}` -- **Deploy:** `bash deploy/ecs/redeploy.sh` (the only blessed redeploy command — does build + custom/ rsync + binary swap + restart + .last-deploy marker) -- **Pull prod DB to Mac:** `bash scripts/sync-from-prod.sh --confirm` (one-way; for reproducing prod state locally) -- **Setup docs:** [`deploy/ecs/README.md`](deploy/ecs/README.md), [`deploy/ecs/cutover-runbook.md`](deploy/ecs/cutover-runbook.md), [`docs/superpowers/specs/2026-05-01-prod-cloud-migration-design.md`](docs/superpowers/specs/2026-05-01-prod-cloud-migration-design.md) - -## Mac Dev / Warm-Standby Instance -- URL: https://hackforger.inside.h2os.cloud (Caddy → localhost:3000, Tailscale-only) -- This is where Claude does E2E testing (gitflow stage 2) and admins do manual sign-off (stage 3) -- Login: hackforger or `SynNovator` / `$HACKFORGER_ADMIN_PASSWORD` (whichever the latest prod sync brought in) -- DB: `hackforger` on Postgres (gets overwritten by `sync-from-prod.sh` on demand) -- Caddy reverse proxy: managed by launchd (com.h2os.caddy), do NOT restart or unload -- Check Caddy status: `launchctl list com.h2os.caddy` -- Caddy config: ~/.config/caddy/ (Caddyfile, env, run.sh) -- ⚠️ If Caddy config reload is needed, MUST confirm with developer first: `caddy reload --config ~/.config/caddy/Caddyfile` -- Default branch: v0.1-dev/hackforger -- Restart: `bash scripts/restart-gitea.sh` -- Nightly backup pull from cloud: launchd `com.h2os.hackforger-backup-pull` at 04:30 → `~/Backups/hackforger/` - -## Test Instance -- URL: http://localhost:3001 (no Caddy, direct) -- Custom path: `/tmp/hackforger-test-custom` (contains `conf/app.ini`) -- Work path: `/tmp/hackforger-test-data` -- DB: `hackforger_test` on Postgres (separate from prod's `hackforger`) -- Login: hackforger / `$HACKFORGER_TEST_ADMIN_PASSWORD` (**different from prod**) -- Restart: `bash scripts/restart-gitea-test.sh` (rebuild + safe restart) -- Use for: E2E tests, schema/migration experiments, anything you don't want hitting prod data -- ⚠️ Do NOT share `WORK_PATH` with the prod instance (LevelDB lock conflict) -- ⚠️ Action runners are bound to one instance via `.runner` file — a runner registered against port 3000 will NOT pick up jobs dispatched on 3001. For test E2E that involves Actions, register a separate runner with its own config dir. - -## Action Runners (local dev machine) -- **Production runner**: `~/.config/forgejo-runner/` — daemon under launchd `com.h2os.forgejo-runner`, auto-starts at login -- **Test runner**: `~/.config/forgejo-runner-test/` — registered against port 3001 but NOT under launchd; start manually only when running E2E - - Start: `cd ~/.config/forgejo-runner-test && nohup ~/.local/bin/forgejo-runner daemon > runner.log 2>&1 & disown` - - Stop: `pkill -f 'forgejo-runner daemon$'` (matches the no-flag invocation; spares the prod daemon which uses `--config config.yml`) -- Both runners use `host` mode labels (`ubuntu-latest:host`, `macos-arm64:host`) — jobs run directly on the mac, no Docker required. Beware: `runs-on: ubuntu-latest` in YAML actually runs against macOS BSD shell tools. - -## Git Remotes -- `origin` -- git@github.com:HackForger/hackforger.git (our repo) -- `upstream` -- https://codeberg.org/forgejo/forgejo.git (Forgejo upstream, read-only) +Use `gh` for the GitHub-hosted repository. Do not add Forgejo Actions for GitHub CI. diff --git a/custom/options/hackforger-help/platform.en-US.md b/custom/options/hackforger-help/platform.en-US.md deleted file mode 100644 index 0b55f69cf5..0000000000 --- a/custom/options/hackforger-help/platform.en-US.md +++ /dev/null @@ -1,21 +0,0 @@ -# SynNovator Web Platform User Manual - -> The English version of this manual is being updated to match the new Chinese manual ([issue #106](https://github.com/HackForger/hackforger/issues/106)). For now, please refer to the Chinese version at the language switcher (zh-CN) for the most up-to-date guidance. - -The new manual covers: - -- Manual scope and key concepts -- Quick links to all platform pages -- Account and personal space -- Home feed, activity, and discovery -- Participant flow: registration → development → submission -- Team and organization collaboration -- Bounty browsing, creation, and completion -- Grant rounds: browsing, applying, and managing -- Credits, redemption, and rewards -- Reputation and leaderboards -- Notifications, follows, and interactions -- Troubleshooting common issues -- Recommended workflows for participants and teams - -English translation is tracked in issue #106. Contributions welcome. diff --git a/custom/options/hackforger-help/platform.zh-CN.md b/custom/options/hackforger-help/platform.zh-CN.md deleted file mode 100644 index 7af5659020..0000000000 --- a/custom/options/hackforger-help/platform.zh-CN.md +++ /dev/null @@ -1,527 +0,0 @@ -# SynNovator 平台网页端使用手册 - -> 本手册面向人工访问 SynNovator 网页的用户,覆盖平台网页端的常见操作路径、前置条件、操作步骤、结果确认和常见问题。不包含龙虾、飞书机器人、API、脚本或自动化操作。 - -## 1. 手册范围 - -### 1.1 适用对象 - -- 参赛用户:报名活动、维护仓库、提交作品、查看评审和奖励。 -- 团队成员:通过组织和仓库共同协作完成同一项目。 -- 活动组织者:创建活动、配置赛道、管理报名、评委、评审标准和结果。 -- 评委:查看待评作品并在网页端完成评分。 -- 管理员:管理 Credits、兑换订单、声望规则和活动阶段类型。 - -### 1.2 不包含内容 - -- 不包含龙虾队友、飞书 IM、飞书群通知或机器人指令。 -- 不包含 API 调用、curl、命令行脚本或自动化测试流程。 -- 不替代具体活动规则。活动详情页中的赛题、阶段时间、交付物要求和奖项说明优先级高于本手册。 - -### 1.3 重要概念 - -| 概念 | 含义 | -| --------------------- | ------------------------------------------------------------------------ | -| Hackathon / 活动 | 一次比赛或黑客松,包含报名、开发、评审、结果阶段。 | -| Track / 赛道 | 活动下的方向或分组,通常对应一个组织内仓库。 | -| Repository / 仓库 | 用户或团队的项目代码、文档和交付物所在位置。 | -| Submission / 作品提交 | 用户在活动中提交的项目记录,包含标题、描述、仓库、赛道和演示地址。 | -| Pull Request / PR | 从参赛仓库向赛道仓库发起的代码提交请求。部分活动会要求用 PR 作为提交物。 | -| Organization / 组织 | 团队或活动组织空间,用于管理成员、团队和仓库权限。 | -| Credits | 平台积分,可作为活动奖励、悬赏奖励、资助发放和兑换凭证。 | -| Reputation / 声望 | 平台根据活动、悬赏、资助等贡献计算的长期影响力分数。 | - -## 2. 快速入口 - -| 目标 | 页面路径 | -| ------------------ | ------------------------------------- | -| 查看首页和社区动态 | `/` | -| 平台帮助中心 | `/help` | -| 探索活动 | `/explore/hackathons` | -| 探索作品 | `/explore/submissions` | -| 探索悬赏 | `/explore/bounties` | -| 探索资助 | `/explore/grants` | -| Credits 排行榜 | `/explore/credits` | -| 声望排行榜 | `/explore/reputation` | -| 创建活动 | `/hackathons/new` | -| 活动详情 | `/hackathon/{活动 slug}` | -| 活动提交 | `/hackathon/{活动 slug}/submit` | -| 活动排行榜 | `/hackathon/{活动 slug}/leaderboard` | -| 创建资助轮次 | `/grants/new` | -| 资助轮次详情 | `/grants/{资助 slug}` | -| 我的 Credits | `/credits` | -| 我的兑换订单 | `/credits/orders` | - -## 3. 账号与个人空间 - -### 3.1 注册和登录 - -1. 打开平台首页。 -2. 点击登录或注册入口。 -3. 按页面提示完成账号注册或第三方登录。 -4. 登录后进入首页仪表板。 - -完成后确认: - -- 右上角显示自己的头像或用户名。 -- 可以进入个人主页、设置页、仓库列表和组织列表。 -- 首页出现社区动态、参与活动或项目相关信息。 - -常见问题: - -- 如果无法登录,先确认账号是否已激活、第三方授权是否成功、浏览器是否阻止 Cookie。 -- 如果登录后看不到活动操作按钮,确认该按钮是否要求登录、报名、活动阶段或权限。 - -### 3.2 完善个人资料 - -1. 点击右上角头像。 -2. 进入设置或个人资料页面。 -3. 补充昵称、头像、邮箱、个人简介、网站等信息。 -4. 保存修改。 - -建议: - -- 参赛用户应使用容易识别的用户名和头像。 -- 团队协作时,个人资料应能帮助组织者和队友确认身份。 -- 重要通知可能与邮箱、站内通知或组织成员关系相关,应保持联系方式可用。 - -## 4. 首页、动态和发现 - -### 4.1 查看首页社区动态 - -1. 登录平台。 -2. 进入 `/`。 -3. 查看社区动态、关注动态、活动状态变化、作品提交、悬赏和资助动态。 - -你可以通过动态了解: - -- 哪些活动正在报名、开发或评审。 -- 哪些用户提交了作品。 -- 哪些悬赏或资助项目有新进展。 -- 关注的人和组织最近在做什么。 - -常见问题: - -- 如果动态为空,可能是新账号尚未关注用户、组织或活动。 -- 如果只想看全站内容,进入 Explore 页面查找活动、作品、悬赏和资助。 - -### 4.2 搜索平台内容 - -1. 在平台搜索入口输入关键词。 -2. 可搜索活动、作品、悬赏、资助项目等 SynNovator 实体。 -3. 如果结果太多,使用页面提供的类型筛选或进入对应 Explore 页面。 - -建议关键词: - -- 活动名或赛段名,例如 `AI 应用大赛`。 -- 仓库名或项目名,例如 `智能简历`。 -- 赛道关键词,例如 `DeFi`、`AI Agent`、`RAG`。 -- 用户名、组织名或技术关键词。 - -## 5. 参赛用户:从报名到提交 - -### 5.1 查找可以参加的活动 - -1. 进入 `/explore/hackathons`。 -2. 使用搜索框输入活动名称或关键词。 -3. 使用状态筛选查看报名中、开发中、评审中或已结束活动。 -4. 点击活动卡片进入详情页。 - -在活动详情页重点查看: - -- 活动状态和阶段时间线。 -- 活动说明和交付物要求。 -- 奖励说明。 -- 赛道列表。 -- 已报名项目和已提交作品。 -- 组织者、关联组织和活动仓库。 - -### 5.2 报名前准备仓库 - -报名通常需要选择一个参赛仓库。你可以使用已有仓库,也可以新建仓库。 - -新建仓库: - -1. 点击页面中的创建仓库入口,或访问 `/repo/create`。 -2. 填写仓库名称、可见性、初始化 README 等信息。 -3. 创建后进入仓库页面。 -4. 添加项目 README、代码、文档或初始提交。 - -选择仓库时注意: - -- 仓库归属为个人时,通常视为个人报名。 -- 仓库归属为组织时,通常视为团队报名。 -- 报名后个人或团队归属可能无法随意切换,团队变动应遵循活动规则。 -- 仓库内容应能体现项目目标、当前进度和交付物位置。 - -### 5.3 报名活动 - -1. 进入活动详情页 `/hackathon/{活动 slug}`。 -2. 确认活动状态为报名开放或页面显示报名入口。 -3. 在报名表单中选择参赛仓库。 -4. 选择赛道。 -5. 填写项目标题。 -6. 可填写项目描述和演示链接。 -7. 点击报名按钮。 - -完成后确认: - -- 页面显示"已报名"或类似成功提示。 -- 你的项目出现在参与者列表中。 -- 如果活动绑定组织,你可能会被加入该活动组织。 -- 后续可在活动详情页看到提交入口。 - -常见问题: - -- 看不到报名入口:确认是否已登录、活动是否处于报名阶段、自己是否已报名、自己是否为活动组织者或评委。 -- 仓库下拉为空:确认自己名下或组织下已有可访问仓库。 -- 选错仓库:先查看活动规则;如果平台不允许修改报名仓库,需要联系组织者或在下一赛段处理。 - -### 5.4 开发参赛项目 - -报名成功后,在自己的参赛仓库中持续开发: - -1. 在仓库中维护 README,说明项目背景、使用方式和交付物。 -2. 提交代码、文档、设计稿、演示材料等。 -3. 如活动要求对赛道仓库发起 PR,则从自己的仓库或 Fork 创建 PR。 -4. 确认仓库默认分支包含最终版本内容。 - -建议仓库结构: - -``` -README.md 项目说明、安装、运行、演示和提交说明 -docs/ 方案文档、设计文档、截图 -src/ 或 app/ 主要代码 -tests/ 测试代码或验证材料 -demo/ 演示素材或录屏链接说明 -``` - -### 5.5 提交作品 - -1. 进入活动详情页 `/hackathon/{活动 slug}`。 -2. 确认自己已报名,且活动处于允许提交的阶段。 -3. 点击提交入口,进入 `/hackathon/{活动 slug}/submit`。 -4. 填写作品标题。 -5. 填写作品描述,说明项目做了什么、如何运行、核心亮点和交付物位置。 -6. 填写演示 URL。如果没有线上演示,可填写视频、文档或 README 中的演示链接。 -7. 选择提交仓库。 -8. 选择赛道。 -9. 点击提交。 - -完成后确认: - -- 活动详情页的作品列表中出现你的作品。 -- 作品包含标题、仓库、演示链接、赛道等信息。 -- 如果作品已评分,列表或排行榜中会显示分数和排名。 - -常见问题: - -- 没有提交按钮:确认是否已报名、活动是否允许提交。 -- 提交后想修改:在允许提交阶段内再次进入提交页,页面可能会带出已有提交内容供更新;活动规则可能限制最终提交次数。 -- 评审阶段后无法提交:活动已进入评审或结束阶段时,提交通常关闭。 -- 不知道 PR 编号:网页端提交表单当前主要让用户选择仓库和赛道;如果活动明确要求 PR,先在赛道仓库创建 PR,再在作品描述中补充 PR 链接或按活动页面要求填写。 - -### 5.6 查看其他作品并评论 - -1. 进入 `/explore/submissions` 查看全站作品。 -2. 或进入活动详情页查看当前活动作品列表。 -3. 点击作品关联的仓库或 PR。 -4. 在仓库 Issue、PR、Discussion 或评论区进行反馈。 - -评论建议: - -- 先说明你实际查看了哪些内容。 -- 指出项目亮点。 -- 指出可复现的问题和改进建议。 -- 避免人身评价、恶意误导或没有依据的结论。 - -## 6. 团队和组织协作 - -### 6.1 创建组织 - -1. 从页面导航进入创建组织入口。 -2. 填写组织名称、显示名称和简介。 -3. 创建后进入组织主页。 -4. 在组织中创建团队和仓库。 - -适用场景: - -- 多人共同维护一个参赛仓库。 -- 团队希望用组织名义报名。 -- 活动组织方需要统一管理赛道仓库和成员。 - -### 6.2 邀请或添加团队成员 - -1. 进入组织页面。 -2. 进入 Teams 或团队管理页面。 -3. 创建团队,设置权限。 -4. 添加成员用户名。 -5. 保存。 - -注意: - -- 当前产品设计里,部分场景可能没有完整的邀请和申请确认流程。 -- 被加入团队的用户应自行确认自己是否愿意参与该组织和活动。 -- 组织者应避免把用户加入多个冲突团队或同一活动的多个参赛项目。 - -### 6.3 团队参赛 - -1. 团队先创建组织仓库。 -2. 由有权限的成员进入活动详情页报名。 -3. 报名时选择组织仓库。 -4. 团队成员在同一仓库中协作开发。 -5. 提交时选择该组织仓库作为作品仓库。 - -确认团队报名成功: - -- 参与者列表显示团队项目或组织仓库。 -- 组织成员具有仓库读写权限。 -- 仓库的提交记录能体现团队协作。 - -## 7. 悬赏 Bounty - -### 7.1 浏览悬赏 - -1. 进入 `/explore/bounties`。 -2. 使用状态或模式筛选悬赏。 -3. 点击悬赏关联 Issue 或仓库查看详情。 - -悬赏通常包含: - -- 标题和描述。 -- 关联 Issue。 -- 奖励金额或 Credits。 -- 模式:独占、竞争、多人获奖等。 -- 状态:开放、已认领、待审核、完成、已支付、过期或取消。 - -### 7.2 在仓库中创建悬赏 - -1. 进入目标仓库。 -2. 进入 Issues。 -3. 创建或打开一个 Issue。 -4. 如果仓库页面提供 Bounty 入口,进入 Bounties / New。 -5. 填写悬赏标题、描述、奖励、截止时间和模式。 -6. 提交创建。 - -注意: - -- 悬赏通常绑定一个 Issue。 -- 一旦 Issue 绑定悬赏,Issue 标题和正文可能被锁定或限制编辑,以保护悬赏信息一致性。 - -### 7.3 申请或完成悬赏 - -1. 打开悬赏关联 Issue。 -2. 阅读任务要求和奖励规则。 -3. 申请悬赏或提交解决方案。 -4. 在 Issue、PR 或评论中说明完成内容。 -5. 等待创建者审核、选择获奖者或支付奖励。 - -## 8. 资助 Grant - -### 8.1 浏览资助轮次 - -1. 进入 `/explore/grants`。 -2. 查看开放、评审中、已结束或已发放的资助轮次。 -3. 点击资助轮次进入详情页 `/grants/{资助 slug}`。 - -详情页可查看: - -- 资助状态。 -- 总预算和已使用金额。 -- 描述和申请条件。 -- 项目列表。 -- 提交入口。 - -### 8.2 提交资助申请 - -1. 进入开放中的资助轮次详情页。 -2. 点击提交项目。 -3. 填写项目名称、描述、申请金额或 Credits。 -4. 填写仓库链接、演示链接或其他材料。 -5. 提交。 - -完成后确认: - -- 项目出现在资助项目列表。 -- 状态通常为待审核。 -- 后续组织者会批准、拒绝、确定金额或发放资助。 - -### 8.3 组织者管理资助轮次 - -1. 进入 `/grants/new` 创建资助轮次。 -2. 填写名称、slug、预算、币种、Credits 预算和说明。 -3. 创建后进入 `/grants/{资助 slug}/manage`。 -4. 根据阶段打开申请、关闭申请、进入评审、确认资助结果、发放资助或取消轮次。 -5. 在项目管理页批准、拒绝、设置获奖金额或发放项目资助。 - -## 9. Credits、兑换和奖励 - -### 9.1 查看我的 Credits - -1. 登录后进入 `/credits`。 -2. 查看当前余额。 -3. 查看交易历史。 -4. 查看可兑换选项。 - -交易历史中可能出现: - -- 活动奖励入账。 -- 悬赏奖励入账。 -- 资助入账。 -- 兑换扣款。 -- 管理员充值或扣款。 -- 退款或托管释放。 - -### 9.2 兑换奖励 - -1. 在 `/credits` 查看可兑换选项。 -2. 点击目标奖励的兑换按钮。 -3. 进入 `/credits/redeem/{奖励 id}` 确认兑换。 -4. 确认需要消耗的 Credits、库存和说明。 -5. 点击确认。 -6. 进入 `/credits/orders` 查看订单状态。 - -订单状态含义: - -| 状态 | 含义 | -| --------- | -------------------------- | -| pending | 已扣款,等待管理员履约。 | -| fulfilled | 管理员已发放奖励或密钥。 | -| cancelled | 订单被取消,可能伴随退款。 | - -常见问题: - -- 余额不足:无法兑换高于余额的奖励。 -- 库存不足:等待管理员补库存或选择其他奖励。 -- 兑换后没有收到密钥:进入订单页查看状态;如果仍为 pending,等待管理员履约。 - -### 9.3 查看 Credits 排行榜 - -1. 进入 `/explore/credits`。 -2. 查看用户 Credits 排名。 -3. 点击用户头像或用户名查看个人主页。 - -## 10. 声望和排行榜 - -### 10.1 查看声望排行榜 - -1. 进入 `/explore/reputation`。 -2. 查看用户总声望、等级和排名。 -3. 进入用户主页查看更详细的个人资料和贡献记录。 - -声望通常来自: - -- Hackathon 名次和作品表现。 -- Bounty 完成和获奖。 -- Grant 项目获资助。 -- 平台配置的其他贡献权重。 - -### 10.2 查看活动排行榜 - -1. 进入活动详情页。 -2. 活动结束后点击排行榜。 -3. 或直接访问 `/hackathon/{活动 slug}/leaderboard`。 -4. 查看作品排名、分数和奖励信息。 - -注意: - -- 活动结束前,排行榜可能不可见或只显示未最终确认的数据。 -- 最终排名以组织者 finalize 后的公开结果为准。 - -## 11. 通知、关注和互动 - -### 11.1 关注用户或组织 - -1. 进入用户或组织主页。 -2. 点击关注。 -3. 回到首页查看关注动态。 - -关注适合: - -- 追踪组织者发布的新活动。 -- 追踪队友和竞品项目动态。 -- 追踪优秀开发者的提交和作品。 - -### 11.2 Star、Watch 和评论 - -1. 进入仓库页面。 -2. 使用 Star 表达关注或支持。 -3. 使用 Watch 订阅仓库通知。 -4. 在 Issue、PR 或 Discussion 中评论。 - -建议: - -- 对作品的技术反馈优先写在 PR 或 Issue,便于作者定位。 -- 活动相关的问题优先写在活动说明指定的讨论区或仓库 Issue。 - -## 12. 常见问题排查 - -### 12.1 找不到某个页面入口 - -可直接访问本手册的快速入口 URL。当前部分后台和 Credits 页面可能需要直接输入路径,或从管理员导航进入。 - -### 12.2 看不到按钮 - -通常由以下原因导致: - -- 未登录。 -- 账号没有对应权限。 -- 活动阶段不允许该操作。 -- 你已经完成过该操作。 -- 你是活动组织者或评委,因此不能作为参赛者执行某些动作。 - -### 12.3 报名或提交失败 - -检查: - -- 活动是否处于允许报名或提交的阶段。 -- 是否选择了仓库和赛道。 -- 仓库是否属于你或你的组织。 -- 标题是否为空。 -- 演示 URL 格式是否正确。 -- 页面是否有红色错误提示。 - -### 12.4 作品没有出现在列表 - -检查: - -- 提交是否成功保存。 -- 当前页面是否筛选了赛道、状态或关键词。 -- 作品是否仍为草稿或未提交状态。 -- 是否提交到了另一个活动或赛道。 - -### 12.5 奖励没有到账 - -检查: - -- 活动是否已经 finalize。 -- 组织者或管理员是否已发放奖励。 -- `/credits` 交易历史是否有对应记录。 -- `/credits/orders` 是否有待履约兑换订单。 - -## 13. 推荐操作流程清单 - -### 13.1 参赛用户清单 - -- 注册并登录平台。 -- 完善个人资料。 -- 创建或准备参赛仓库。 -- 在 Explore 中找到活动。 -- 阅读活动详情、规则和阶段时间。 -- 报名并选择仓库和赛道。 -- 开发项目并持续提交代码。 -- 在允许提交阶段提交作品。 -- 查看其他作品并参与评论。 -- 关注评审和排行榜。 -- 查看 Credits、奖励和兑换订单。 - -### 13.2 团队清单 - -- 创建组织。 -- 创建组织仓库。 -- 添加团队成员。 -- 确认成员权限。 -- 用组织仓库报名。 -- 团队共同开发。 -- 由负责人提交作品。 -- 活动结束后确认奖励归属。 diff --git a/custom/options/hackforger-help/system.en-US.md b/custom/options/hackforger-help/system.en-US.md deleted file mode 100644 index 8c4266cbe5..0000000000 --- a/custom/options/hackforger-help/system.en-US.md +++ /dev/null @@ -1,45 +0,0 @@ -# HackForger System Guide - -**HackForger** is the underlying system engine of the SynNovator platform. Built on Forgejo, it provides complete Git collaboration capabilities plus extension modules designed for co-creation activities. This page is a capability index — find the feature you need, then follow the link to go deeper. - -## Core Git Collaboration Capabilities - -These capabilities are inherited from Forgejo. Usage is identical to Forgejo / Gitea / GitHub: - -* **Repositories** — Create, Fork, branches, tags, protection rules, template repositories, mirror sync -* **Pull Requests / Merge Requests** — Code review, conflict resolution, checks, auto-merge -* **Issues** — Labels, milestones, project boards, automation, lock/pin -* **Wiki / Project Docs** — Built-in repository Wiki, custom home page -* **Forgejo Actions / Workflows** — `.forgejo/workflows/*.yml` CI/CD, self-hosted runner support -* **Full-text Search** — Multi-dimensional search across code, Issues, Commits, and Wiki -* **Webhooks / External Integrations** — Push code/Issue events to Slack, WeCom, external CI, and more -* **SSH / HTTPS Access** — Personal Access Token (PAT), SSH key management -* **Organizations / Teams** — Multi-level permissions, shared team repositories - -If you are familiar with GitHub, the workflows here are nearly identical. For detailed usage documentation, refer to the upstream [Forgejo Docs](https://forgejo.org/docs/latest/). - -## HackForger Platform Extension Modules - -These are the modules HackForger adds on top of Forgejo to power SynNovator's co-creation activities: - -| Module | Description | Access Path | -|--------|-------------|-------------| -| Hackathon | Event creation, registration, tracks, judges, scoring, leaderboard | `/explore/hackathons` | -| Bounty | Issue linking, apply/claim, escrow, multi-party prize pool | `/explore/bounties` | -| Grant | Funding rounds, project applications, review, allocation | `/explore/grants` | -| Credits | Platform-wide credits ledger, redemption store, order history | `/credits` | -| Submissions | Aggregated browsing of submissions across all activities | `/explore/submissions` | -| Feed | Activity/Bounty/Grant/Credits event stream | `/` dashboard | -| Reputation | User participation metrics, leaderboard, tier badges | `/explore/reputation` | - -## Developer Resources - -* **API Reference** — Visit `/api/swagger` for the full OpenAPI documentation; HackForger-specific endpoints start with `/api/v1/hackforger/` -* **CLI Tool** — `hackforger-cli` (repository root, Go implementation, for bulk creation and management of activities) -* **AI Agent Integration** — Authorize via PAT to let an AI agent operate the platform API on your behalf - -## Need Help? - -* Platform feature questions — Read the "Platform Guide (SynNovator)" section on this page -* Git / repository questions — Refer to [Forgejo Docs](https://forgejo.org/docs/latest/) -* Found a bug — Open an issue in the [HackForger GitHub repository](https://github.com/HackForger/hackforger/issues) diff --git a/custom/options/hackforger-help/system.zh-CN.md b/custom/options/hackforger-help/system.zh-CN.md deleted file mode 100644 index 7813e328b6..0000000000 --- a/custom/options/hackforger-help/system.zh-CN.md +++ /dev/null @@ -1,45 +0,0 @@ -# HackForger 使用指南 - -**HackForger** 是 SynNovator 平台的底层系统引擎,基于 Forgejo 构建,提供完整的 Git 协作能力,并加入了面向协创活动的扩展模块。本页是能力清单 —— 找到你需要的功能,按链接深入。 - -## Git 协作基础能力 - -这些能力继承自 Forgejo,使用方法与 Forgejo / Gitea / GitHub 一致: - -* **仓库 (Repositories)** — 创建、Fork、分支、标签、保护规则、模板仓库、镜像同步 -* **Pull Request / 合并请求** — 代码评审、冲突解决、检查、自动合并 -* **Issue / 议题** — 标签、里程碑、看板、自动化、锁定/置顶 -* **Wiki / 项目文档** — 仓库内置 Wiki、自定义主页 -* **Forgejo Actions / 工作流** — `.forgejo/workflows/*.yml` CI/CD,自托管 runner 支持 -* **全文搜索** — 代码、Issue、Commit、Wiki 多维度搜索 -* **Webhook / 外部集成** — 推送代码/Issue 等事件到 Slack、企微、外部 CI 等 -* **SSH / HTTPS 访问** — 个人访问令牌 (PAT)、SSH key 管理 -* **组织 / 团队** — 多级权限、团队仓库共享 - -如果你熟悉 GitHub,这里的操作习惯几乎完全一致。详细使用文档可参考上游 [Forgejo Docs](https://forgejo.org/docs/latest/)。 - -## HackForger 平台扩展模块 - -这些是 HackForger 在 Forgejo 之外新增的模块,用于支撑 SynNovator 的协创活动: - -| 模块 | 说明 | 访问路径 | -|------|------|---------| -| 黑客松 Hackathon | 活动创建、报名、赛道、评委、评分、排行榜 | `/explore/hackathons` | -| 悬赏 Bounty | Issue 关联、申请/接单、托管、多人竞争奖金 | `/explore/bounties` | -| 资助 Grant | 资助轮次、项目申请、评审、分配 | `/explore/grants` | -| 积分 Credits | 平台统一积分账本、兑换商店、订单记录 | `/credits` | -| 提交作品 Submissions | 跨活动的作品聚合浏览 | `/explore/submissions` | -| 社区动态 Feed | 活动/悬赏/资助/积分事件流 | `/` 控制面板 | -| 声誉 Reputation | 用户参与度量、排行榜、层级徽章 | `/explore/reputation` | - -## 开发者资源 - -* **API 参考** — `/api/swagger` 查看完整 OpenAPI 文档;HackForger 专用接口以 `/api/v1/hackforger/` 开头 -* **CLI 工具** — `hackforger-cli`(仓库根目录,Go 实现,用于批量创建/管理活动) -* **AI Agent 集成** — 通过 PAT 授权让 AI 代表用户操作平台 API - -## 需要帮助? - -* 平台功能问题 —— 阅读本页"SynNovator 平台玩法"区 -* Git / 仓库问题 —— 参考 [Forgejo Docs](https://forgejo.org/docs/latest/) -* 发现 bug —— 在 [HackForger GitHub 仓库](https://github.com/HackForger/hackforger/issues) 提 issue diff --git a/custom/templates/hackforger/help.tmpl b/custom/templates/hackforger/help.tmpl deleted file mode 100644 index 61493ae81b..0000000000 --- a/custom/templates/hackforger/help.tmpl +++ /dev/null @@ -1,43 +0,0 @@ -{{/* HackForger override (#96): the bundled locale value for - hackforger.help.platform.title is "平台玩法 (Synnovator)" / "Platform Guide (Synnovator)" - — old casing of the SynNovator trademark (camelCase, capital N). - - We can't fix it via custom/options/locale/ because Forgejo's locale loader - is file-level first-wins, not per-key merge (see PR #97 / #98 — that - approach killed gitea startup by shadowing all bundled keys). - - Cleanest fix: override THIS template (the only consumer of that key) and - render the title literally with correct casing. Keeps upstream locale file - untouched; "SynNovator" trademark is the same string in any language so - no further i18n is needed for that fragment. */}} -{{template "base/head" .}} -
-
-

{{ctx.Locale.Tr "hackforger.help.title"}}

- -
-
-
- {{svg "octicon-rocket" 16}} - {{if eq ctx.Locale.Lang "zh-CN"}}平台玩法 (SynNovator){{else}}Platform Guide (SynNovator){{end}} -
-
-
-
{{.SynnovatorHTML}}
-
-
- -
-
-
- {{svg "octicon-tools" 16}} - {{ctx.Locale.Tr "hackforger.help.system.title"}} -
-
-
-
{{.HackforgerHTML}}
-
-
-
-
-{{template "base/footer" .}} diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile deleted file mode 100644 index 7eba209d4b..0000000000 --- a/deploy/caddy/Caddyfile +++ /dev/null @@ -1,25 +0,0 @@ -# LEGACY — 早期 Docker 版 inside-caddy 的配置,已被宿主机原生 launchd Caddy 取代 -# (~/.config/caddy/Caddyfile)。仅作历史参考,勿用于现行部署。详见本目录 README.md。 -*.inside.h2os.cloud { - tls { - dns alidns { - access_key_id {env.ALIDNS_ACCESS_KEY_ID} - access_key_secret {env.ALIDNS_ACCESS_KEY_SECRET} - } - } - - @hackforger host hackforger.inside.h2os.cloud - handle @hackforger { - reverse_proxy host.docker.internal:3000 - } - - # Add more services here: - # @another host another.inside.h2os.cloud - # handle @another { - # reverse_proxy host.docker.internal:8080 - # } - - handle { - respond "Service not configured" 404 - } -} diff --git a/deploy/caddy/Dockerfile b/deploy/caddy/Dockerfile deleted file mode 100644 index 1f2e644e92..0000000000 --- a/deploy/caddy/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -# LEGACY — 早期 Docker 版 inside-caddy 的镜像,已被宿主机原生 launchd Caddy 取代。 -# 注意:此镜像未编 layer4 插件,功能弱于现行原生部署。详见本目录 README.md。 -FROM caddy:2-builder AS builder - -# 国内拉取 Go module 走镜像,避免 xcaddy build 卡在 proxy.golang.org / GitHub -ENV GOPROXY=https://goproxy.cn,direct - -RUN xcaddy build \ - --with github.com/caddy-dns/alidns - -FROM caddy:2-alpine - -COPY --from=builder /usr/bin/caddy /usr/bin/caddy diff --git a/deploy/caddy/README.md b/deploy/caddy/README.md deleted file mode 100644 index 7fac086969..0000000000 --- a/deploy/caddy/README.md +++ /dev/null @@ -1,99 +0,0 @@ -# 本机内网 ingress(`*.inside.h2os.cloud`) - -本机所有 `*.inside.h2os.cloud` 的 HTTPS 反向代理由**宿主机原生 Caddy**(经 launchd 常驻)提供, -通过 **AliDNS DNS-01** 自动签发 `*.inside.h2os.cloud` 泛域名证书。仅经 Tailscale 内网访问。 - -> ⚠️ **权威部署是下面的「原生 Caddy」**。本目录里的 `Dockerfile` / `Caddyfile` 是**早期 Docker 版,已废弃** -> (只代理 hackforger 一个服务、且没编 `layer4` 插件),保留仅作历史参考,见文末「Legacy」。 - ---- - -## 一、现行部署:原生 Caddy(launchd 常驻) - -所有运行态文件都在宿主机 `~/.config/caddy/`(**不在本仓库**,因为它服务多个项目、且含密钥): - -| 位置 | 作用 | -|---|---| -| launchd `com.h2os.caddy` | 常驻守护(`RunAtLoad` + `KeepAlive`);plist:`~/Library/LaunchAgents/com.h2os.caddy.plist` | -| `~/.config/caddy/run.sh` | 入口:`source ~/.config/caddy/env` 注入密钥后 `exec caddy run` | -| `~/.config/caddy/env` | `ALICLOUD_ACCESS_KEY_ID` / `ALICLOUD_ACCESS_KEY_SECRET`(权限 600,**不入库**) | -| `~/.config/caddy/Caddyfile` | 路由 + TLS 配置(含 `layer4`) | -| `~/.config/caddy/caddy.log` | 运行日志(stdout/stderr) | -| `/usr/local/bin/caddy` | 二进制,**v2.11.2,带 `layer4` + `alidns` 插件** | - -**当前代理的服务:** - -| 入口 | 上游 | 备注 | -|---|---|---| -| `hackforger.inside.h2os.cloud` | `localhost:3000` | HackForger / Gitea | -| `saneledger.inside.h2os.cloud` | `localhost:54722` | 前置 oauth2-proxy(`127.0.0.1:4180`,`forward_auth`) | -| `zchat.inside.h2os.cloud`(SNI) | `127.0.0.1:6667` | **layer4** 在 `:6697` 按 SNI 透传 TLS | -| 其它 `*.inside.h2os.cloud` | — | 兜底 `404 Not Found` | - -### 启动链路 - -``` -launchd(com.h2os.caddy) → ~/.config/caddy/run.sh → source env(ALICLOUD_*) → caddy run --config ~/.config/caddy/Caddyfile -``` - -### 常用运维 - -```bash -# 看日志(证书签发 / 反代错误) -tail -f ~/.config/caddy/caddy.log - -# 改了 ~/.config/caddy/Caddyfile 后生效: -# 注意 Caddyfile 里设了 `admin off`,所以不能用 `caddy reload`,要重启服务: -launchctl kickstart -k gui/$(id -u)/com.h2os.caddy - -# 停 / 起 -launchctl bootout gui/$(id -u)/com.h2os.caddy # 停 -launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.h2os.caddy.plist # 起 - -# 验证(经 Tailscale,且对应上游需在跑) -curl -I https://hackforger.inside.h2os.cloud -``` - -### 重建 caddy 二进制(需带 layer4 + alidns) - -`/usr/local/bin/caddy` 必须同时带 `layer4`(zchat 透传)和 `alidns`(泛域名证书)两个插件: - -```bash -xcaddy build \ - --with github.com/mholt/caddy-l4 \ - --with github.com/caddy-dns/alidns -sudo install -m 0755 ./caddy /usr/local/bin/caddy -launchctl kickstart -k gui/$(id -u)/com.h2os.caddy -``` - -> 国内构建设 `GOPROXY=https://goproxy.cn,direct` 即可拉动 Go module。 - -### 新增一个内网服务 - -编辑 `~/.config/caddy/Caddyfile`,仿照 `@hackforger` 块加一段,然后 `launchctl kickstart -k …` 重启: - -```caddyfile -@foo host foo.inside.h2os.cloud -handle @foo { - reverse_proxy localhost:PORT -} -``` - ---- - -## 二、Legacy:Docker 版(已废弃,勿用于现行部署) - -本目录的 `Dockerfile` / `Caddyfile` 是更早的 Docker 方案,**约 2026-04 起已被上面的原生 Caddy 取代**, -容器 `inside-caddy` 早已停用。差异: - -- 只代理 `hackforger` 一个服务,**缺 `layer4`**(没有 zchat / saneledger)。 -- 密钥变量名是 `ALIDNS_ACCESS_KEY_*`(原生版是 `ALICLOUD_ACCESS_KEY_*`)。 -- 上游用 `host.docker.internal:3000`(原生版直接 `localhost:3000`)。 - -保留它们仅供历史参考。如确需在容器里跑(例如换机临时验证),镜像构建为: - -```bash -cd deploy/caddy && docker build -t caddy-alidns:latest . # Dockerfile 已内置 GOPROXY -``` - -证书数据曾存于 Docker 具名卷 `caddy_data`;**与原生 Caddy 的 `~/.config/caddy` 存储相互独立**。 diff --git a/deploy/ecs/README.md b/deploy/ecs/README.md deleted file mode 100644 index b5da9d8903..0000000000 --- a/deploy/ecs/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# deploy/ecs/ — HackForger production cloud deployment - -Artifacts for deploying HackForger to the Huawei Cloud ECS at `203.119.115.130`, -serving `https://www.synnovator.com` as native binaries managed by systemd. - -> **Not to be confused with `deploy/caddy/`** (sibling dir) — that's the **Mac -> developer instance**'s Caddy + Tailscale + alidns setup for -> `hackforger.inside.h2os.cloud`. This `ecs/` dir is the **public cloud** -> deployment. - -**Spec:** [`docs/superpowers/specs/2026-05-01-prod-cloud-migration-design.md`](../../docs/superpowers/specs/2026-05-01-prod-cloud-migration-design.md) -**Plan:** [`docs/superpowers/plans/2026-05-01-prod-cloud-migration.md`](../../docs/superpowers/plans/2026-05-01-prod-cloud-migration.md) -**Cutover runbook:** [`cutover-runbook.md`](cutover-runbook.md) - -## Files - -| File | Side | Purpose | -| ---- | ---- | ------- | -| `build-linux.sh` | Mac | Cross-compile `gitea` for `linux/amd64` via docker. | -| `ecs-bootstrap.sh` | ECS | Idempotent install of postgres, caddy, runner, dirs, systemd units. Run once. | -| `migrate-data.sh` | Mac+ECS | One-shot dump-rsync-restore the post-clean-slate prod DB + data dirs. Run once at cutover. | -| `redeploy.sh` | Mac→ECS | **Every subsequent deploy.** Build → rsync custom/ → scp binary → restart → record .last-deploy. | -| `preflight.sh` | Mac | Refuses to ship commits without admin-signed e2e reports. Called by `redeploy.sh`. | -| `backup-ecs.sh` | ECS | Nightly `pg_dump` + 14-day rotation. Runs from systemd timer. | -| `app.ini.tmpl` | ECS | Production `app.ini` template. Bootstrap script renders it. | -| `caddy/Caddyfile.tmpl` | ECS | Caddy config for `www.synnovator.com`. | -| `systemd/*.service` `*.timer` | ECS | systemd units installed by `ecs-bootstrap.sh`. | -| `launchd/com.h2os.hackforger-backup-pull.plist` | Mac | launchd job that pulls backups from ECS. | -| `../../scripts/backup-pull.sh` | Mac | What the launchd job invokes. | - -## Order of operations (first-time deploy) - -1. **Manual prerequisites (operator):** - - Open inbound :80 + :443 in the Huawei Cloud security group for ECS 203.119.115.130. - - In the Aliyun DNS console: lower `synnovator.com` zone TTL to 60s, add A record `www → 203.119.115.130`. - - In Logto admin: add `https://www.synnovator.com/user/oauth2//callback` to the allowed callback URLs. -2. `bash deploy/ecs/build-linux.sh` on the Mac → produces `gitea-linux-amd64`. -3. `scp -r deploy/ecs hackforger@203.119.115.130:/tmp/` and `bash /tmp/ecs/ecs-bootstrap.sh` (interactive sudo). -4. `scp gitea-linux-amd64 hackforger@203.119.115.130:/opt/hackforger/gitea`. -5. Run `bash deploy/ecs/migrate-data.sh --confirm` from the Mac during the maintenance window. -6. Follow `cutover-runbook.md`. - -## Subsequent deploys (after first cutover) - -```bash -bash deploy/ecs/redeploy.sh -``` - -That's it. The script does: -1. **Preflight** — `preflight.sh` refuses to ship commits without admin-signed - e2e reports (see `docs/notes/gitflow.md`). -2. **Build** — `build-linux.sh` cross-compiles via docker. -3. **Rsync custom overrides** — `custom/templates/` and non-landing files - present under `custom/public/` go to the box. This is **load-bearing** for template - and public overrides, but the externally managed `assets/landing/` subtree - is excluded and protected from `--delete`; this script does not publish or - remove landing pages. -4. **scp + atomic install** — binary lands at `/opt/hackforger/gitea`. -5. **Restart gitea** + record HEAD SHA in `/var/lib/hackforger/.last-deploy`. -6. **Smoke tests** — public `/api/v1/version`, navbar locale string, HTTPS 200. - -Hotfix override (skip the smoke-test gate): -```bash -bash deploy/ecs/redeploy.sh --skip-preflight -``` -…but write the report afterward — the gate exists so we don't rely on -discipline. - -### Why `custom/` isn't auto-bundled into the binary - -Forgejo's `custom/` directory is the override layer. Anything you drop into -`custom/templates/` overrides the bindata-embedded template at the -same path; same for `custom/public/`. The build doesn't see these files -because they're in the runtime filesystem on the target host. So every deploy -needs to push the non-landing overrides too. The ignored `assets/landing/` subtree -is the exception: `redeploy.sh` deliberately preserves it and it must be -managed through its separate, non-destructive workflow. - -If a future PR moves all template overrides under `templates/` (and gets -them into bindata), this step can drop out of `redeploy.sh`. Until then it -stays. diff --git a/deploy/ecs/app.ini.tmpl b/deploy/ecs/app.ini.tmpl deleted file mode 100644 index 7471731837..0000000000 --- a/deploy/ecs/app.ini.tmpl +++ /dev/null @@ -1,50 +0,0 @@ -APP_NAME = HackForger -RUN_MODE = prod -WORK_PATH = /var/lib/hackforger - -[database] -DB_TYPE = postgres -HOST = 127.0.0.1:5432 -NAME = hackforger -USER = hackforger -PASSWD = __PG_PASSWORD__ -SSL_MODE = disable -LOG_SQL = false - -[repository] -ROOT = /var/lib/hackforger/data/forgejo-repositories - -[server] -DOMAIN = www.synnovator.com -HTTP_ADDR = 127.0.0.1 -HTTP_PORT = 3000 -ROOT_URL = https://www.synnovator.com/ -DISABLE_SSH = false -SSH_ALLOW_UNEXPECTED_AUTHORIZED_KEYS = true -LFS_START_SERVER = false - -[lfs] -PATH = /var/lib/hackforger/data/lfs - -[security] -INSTALL_LOCK = true -INTERNAL_TOKEN = __INTERNAL_TOKEN__ - -[log] -MODE = console -LEVEL = Info -ROOT_PATH = /var/lib/hackforger/log - -[actions] -ENABLED = true -DEFAULT_ACTIONS_URL = https://code.forgejo.org - -[oauth2] -JWT_SECRET = __OAUTH2_JWT_SECRET__ - -[ui] -DEFAULT_THEME = hackforger-dark - -[service] -DISABLE_REGISTRATION = false -ALLOW_ONLY_EXTERNAL_REGISTRATION = true diff --git a/deploy/ecs/backup-ecs.sh b/deploy/ecs/backup-ecs.sh deleted file mode 100755 index e2383953a1..0000000000 --- a/deploy/ecs/backup-ecs.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/bash -# Nightly backup of HackForger DB. Invoked by systemd timer -# /etc/systemd/system/hackforger-backup.timer. -# -# - Dumps the Postgres `hackforger` DB to /var/lib/hackforger/pg-backups/ -# - Compressed custom format (-Fc) -# - Filename includes UTC ISO timestamp for natural sort -# - Retains 14 days, deletes older -# -# stdout/stderr go to journald via systemd. - -set -euo pipefail - -BACKUP_DIR=/var/lib/hackforger/pg-backups -RETAIN_DAYS=14 -STAMP=$(date -u +%Y%m%d-%H%M%SZ) -OUT="$BACKUP_DIR/hf-${STAMP}.pgc" - -mkdir -p "$BACKUP_DIR" - -# Read PG password from the file root saved during bootstrap. The hackforger -# user has a sudoers carve-out for just this single read. -PGPASSWORD=$(sudo cat /root/.hackforger-pg-password) \ - pg_dump -Fc -h 127.0.0.1 -U hackforger hackforger \ - > "$OUT" - -SIZE=$(stat -c%s "$OUT") -printf "backup-ecs OK: %s (%d bytes)\n" "$OUT" "$SIZE" - -DELETED=$(find "$BACKUP_DIR" -name 'hf-*.pgc' -mtime "+$RETAIN_DAYS" -delete -print | wc -l) -printf "rotation: deleted %d files older than %d days\n" "$DELETED" "$RETAIN_DAYS" diff --git a/deploy/ecs/build-linux.sh b/deploy/ecs/build-linux.sh deleted file mode 100755 index 709c324c20..0000000000 --- a/deploy/ecs/build-linux.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/bin/bash -# Cross-compile gitea for linux/amd64 using docker, without polluting the Mac -# with a Linux Go toolchain. -# -# Output: ./gitea-linux-amd64 in the repo root. -# Usage: bash deploy/ecs/build-linux.sh -set -euo pipefail - -REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -cd "$REPO" - -OUT="gitea-linux-amd64" -IMAGE="golang:1.25-bookworm" - -if ! command -v docker >/dev/null 2>&1; then - echo "FATAL: docker not found on PATH. Install Docker Desktop." >&2 - exit 1 -fi - -echo "[1/4] Building frontend on host (Node) ..." -# The cross-compile image (golang:1.25-bookworm) only has Debian's Node 18 via -# apt, but Forgejo's frontend build requires Node >= 20. Mounting the repo into -# the container would also clobber the Mac's darwin-native node_modules. So build -# the frontend here on the Mac (Node 20+, correct platform); the container step -# below compiles only the Go backend and embeds these assets via bindata. -make frontend - -echo "[2/4] Pulling build image $IMAGE (no-op if cached)..." -docker pull "$IMAGE" - -echo "[3/4] Cross-compiling backend (embeds host-built frontend via bindata; 3-6 min)..." -docker run --rm --platform linux/amd64 \ - -v "$REPO":/src -w /src \ - "$IMAGE" bash -ec ' - # Use Aliyun mirror — deb.debian.org is unreliable from CN - sed -i "s|deb.debian.org|mirrors.aliyun.com|g; s|security.debian.org|mirrors.aliyun.com|g" \ - /etc/apt/sources.list.d/debian.sources 2>/dev/null || \ - sed -i "s|deb.debian.org|mirrors.aliyun.com|g; s|security.debian.org|mirrors.aliyun.com|g" \ - /etc/apt/sources.list 2>/dev/null || true - apt-get update -qq - apt-get install -y -qq -o Acquire::Retries=3 build-essential git - git config --global --add safe.directory /src - # make backend (NOT make build): compiles Go + embeds public/ via bindata, - # without running the Node frontend target. No node needed in the container. - TAGS="bindata sqlite sqlite_unlock_notify" \ - GOOS=linux GOARCH=amd64 \ - GOPROXY="https://goproxy.cn,direct" \ - make backend - mv gitea '"$OUT"' - ' - -echo "[4/4] Verifying output is a Linux amd64 ELF..." -if file "$OUT" | grep -q "ELF 64-bit LSB.*x86-64"; then - ls -lh "$OUT" - sha256sum "$OUT" 2>/dev/null || shasum -a 256 "$OUT" - echo "✓ Build OK" -else - echo "FATAL: $OUT is not a Linux x86-64 ELF" >&2 - file "$OUT" >&2 - exit 2 -fi diff --git a/deploy/ecs/caddy/Caddyfile.tmpl b/deploy/ecs/caddy/Caddyfile.tmpl deleted file mode 100644 index 47ab836d0b..0000000000 --- a/deploy/ecs/caddy/Caddyfile.tmpl +++ /dev/null @@ -1,27 +0,0 @@ -# HackForger reverse proxy + auto-HTTPS via Let's Encrypt HTTP-01. -# Installed by deploy/ecs/ecs-bootstrap.sh to /etc/caddy/Caddyfile. - -{ - email __ACME_EMAIL__ -} - -www.synnovator.com { - encode gzip zstd - - # Forward to gitea on loopback. Gitea sees the original Host and X-Forwarded-*. - reverse_proxy 127.0.0.1:3000 - - # Sensible defaults for git ops over HTTPS: - # - Larger upload size for git push of repos with binary blobs - request_body { - max_size 500MB - } - - log { - output file /var/log/caddy/access.log { - roll_size 100mb - roll_keep 5 - } - format console - } -} diff --git a/deploy/ecs/cutover-runbook.md b/deploy/ecs/cutover-runbook.md deleted file mode 100644 index 4e5dfaac19..0000000000 --- a/deploy/ecs/cutover-runbook.md +++ /dev/null @@ -1,165 +0,0 @@ -# Cutover Runbook — Mac → ECS migration - -Window: target 30 min, hard limit 60 min. -Spec: [`docs/superpowers/specs/2026-05-01-prod-cloud-migration-design.md`](../../docs/superpowers/specs/2026-05-01-prod-cloud-migration-design.md) - -## T-24h — Prep - -- [ ] In Aliyun DNS console, lower TTL of `synnovator.com` zone to 60s -- [ ] In Huawei console, verify ECS security group has inbound 80 + 443 open from `0.0.0.0/0` - Verify externally: - ```bash - nc -z 203.119.115.130 80 && echo OK - nc -z 203.119.115.130 443 && echo OK - ``` -- [ ] In Logto admin, add allowed callback URL `https://www.synnovator.com/user/oauth2//callback` - (find the exact source name in the Mac DB: - ```bash - psql -h 127.0.0.1 -U hackforger hackforger \ - -c "SELECT name FROM login_source WHERE type=6;" - ``` - ) -- [ ] Notify users in advance (Slack / status page) - -## T-1h — Dry run - -- [ ] On the Mac: - ```bash - bash deploy/ecs/build-linux.sh - file gitea-linux-amd64 # confirm: ELF 64-bit LSB ... x86-64 - scp gitea-linux-amd64 hackforger@203.119.115.130:/opt/hackforger/gitea - ssh hackforger@203.119.115.130 'sudo chmod +x /opt/hackforger/gitea && /opt/hackforger/gitea --version' - ``` -- [ ] Dry-run the data migration (no service stop): - ```bash - bash deploy/ecs/migrate-data.sh --dry-run - ``` - Files should appear at `/tmp/hackforger-prod.pgc` and `/tmp/hackforger-data.tgz` on the ECS. - -## T0 — Cutover - -### (a) Stop Mac gitea (announces 503 to anyone hitting old URL) - -```bash -PID=$(lsof -iTCP:3000 -sTCP:LISTEN -t) -kill "$PID" -``` - -### (b–c) Run the migration script - -```bash -bash deploy/ecs/migrate-data.sh --confirm -``` - -Wait for "Restore complete" output. Ignore the last log line about "start gitea caddy" — we do that next. - -### (d) Start services on ECS - -```bash -ssh hackforger@203.119.115.130 'sudo systemctl start gitea && sudo systemctl start caddy' -``` - -### (e) Smoke test internally on ECS - -```bash -ssh hackforger@203.119.115.130 ' - curl -fsS http://127.0.0.1:3000/api/v1/version -' -``` - -Expected: JSON with version. - -### (f) Verify Caddy from outside (will fail TLS hostname check, that's fine) - -```bash -curl -kfsS https://203.119.115.130/api/v1/version -``` - -Expected: same version JSON. - -### (g) Flip DNS - -In Aliyun DNS console, add/update A record: -``` -www.synnovator.com. 60 IN A 203.119.115.130 -``` - -### (h) Wait for propagation - -```bash -for resolver in 8.8.8.8 1.1.1.1 114.114.114.114 223.5.5.5; do - echo "$resolver: $(dig @$resolver www.synnovator.com +short)" -done -``` - -All four should return `203.119.115.130`. If not, wait 30s and retry. - -### (i) Verify externally over the public hostname - -```bash -curl -fsSL https://www.synnovator.com/api/v1/version -``` - -Expected: JSON, valid HTTPS, no cert warning. -Expected log on ECS: `journalctl -u caddy -n 30 | grep "obtained certificate"` - -### (j) Verify Logto SSO login - -In a browser, open `https://www.synnovator.com/`, click sign-in, complete the OAuth round-trip. Confirm landing on `/dashboard` as `hackforger`. - -### (k) Re-register the runner - -```bash -ADMIN_PASS=$(grep '^HACKFORGER_ADMIN_PASSWORD=' .env | cut -d= -f2) -TOKEN=$(curl -fsS -u hackforger:$ADMIN_PASS \ - https://www.synnovator.com/api/v1/admin/runners/registration-token | jq -r .token) - -ssh hackforger@203.119.115.130 " - cd /var/lib/forgejo-runner - /usr/local/bin/forgejo-runner register \ - --no-interactive \ - --instance http://127.0.0.1:3000 \ - --token $TOKEN \ - --name ecs-prod-runner \ - --labels 'ubuntu-latest:host' - sudo systemctl start forgejo-runner -" -``` - -Verify: -```bash -curl -fsS -u hackforger:$ADMIN_PASS https://www.synnovator.com/api/v1/admin/runners | jq -``` - -## Rollback (if any of d–k fails irrecoverably) - -1. In Aliyun DNS, revert A record to old value (or remove it; the apex `synnovator.com` still resolves to 47.x) -2. Restart Mac gitea: `bash scripts/restart-gitea.sh` -3. Old domain `hackforger.inside.h2os.cloud` independently still works (Tailscale) -4. Diagnose ECS state offline; re-run migrate-data.sh after fix - -Total rollback latency: ~3 min (60s TTL + service restart). - -## T+24h — Verify backup pipeline - -```bash -ls -lt ~/Backups/hackforger/db/ | head -5 -tail -20 ~/Backups/hackforger/backup.log -# Expect a fresh hf-YYYYMMDD-HHMMSSZ.pgc ≤24h old -``` - -If no fresh dump: -```bash -launchctl list | grep hackforger-backup -launchctl print "gui/$UID/com.h2os.hackforger-backup-pull" | head -30 -``` - -Check the ECS-side timer: -```bash -ssh hackforger@203.119.115.130 'systemctl list-timers hackforger-backup.timer' -``` - -## T+30d — Decommission decision - -- If no problems: stop Mac gitea permanently, archive Mac's `data/` to cold storage. -- If something needs Mac for fallback: keep warm-standby until decision-day-2. diff --git a/deploy/ecs/ecs-bootstrap.sh b/deploy/ecs/ecs-bootstrap.sh deleted file mode 100755 index a6e0c7f476..0000000000 --- a/deploy/ecs/ecs-bootstrap.sh +++ /dev/null @@ -1,300 +0,0 @@ -#!/bin/bash -# Idempotent bootstrap of HackForger on the Huawei ECS. -# Run as the hackforger user on 203.119.115.130. Will prompt for sudo password. -# -# Re-running is safe: each step checks state first. -# -# Inputs (env vars, optional): -# ACME_EMAIL — email Caddy gives to Let's Encrypt (default: ops@synnovator.com) -# RUNNER_VERSION — forgejo-runner version (default: v0.3.0) -# -# Outputs: -# Postgres 18 installed and running, hackforger DB+role created -# /var/lib/hackforger/ directory tree created -# /opt/hackforger/ ready for the gitea binary (binary itself is scp'd separately) -# forgejo-runner binary at /usr/local/bin/forgejo-runner -# Caddy installed (apt) and Caddyfile in place -# systemd units installed (NOT all started — gitea + runner wait for data + token) -# /usr/local/bin/hackforger-backup.sh installed -# sudoers carve-out for hackforger to read /root/.hackforger-pg-password - -set -euo pipefail - -# Avoid locale-not-found noise from non-interactive SSH sessions -export LANG=C.UTF-8 LC_ALL=C.UTF-8 - -ACME_EMAIL="${ACME_EMAIL:-ops@synnovator.com}" -RUNNER_VERSION="${RUNNER_VERSION:-v12.9.0}" -CADDY_VERSION="${CADDY_VERSION:-v2.8.4}" -PREBUILT_DIR="${PREBUILT_DIR:-/tmp/binaries-prebuilt}" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -log() { printf "\n\033[1;34m▶ %s\033[0m\n" "$*"; } -ok() { printf " \033[32m✓\033[0m %s\n" "$*"; } -skip() { printf " \033[33m·\033[0m %s (skipped — already done)\n" "$*"; } - -if [ "$(id -un)" != "hackforger" ]; then - echo "FATAL: must run as user 'hackforger', got '$(id -un)'" >&2 - exit 1 -fi - -# Cache sudo creds once at start so the rest is non-interactive. -# Supports SUDO_PASS env var for non-TTY (SSH) invocation; falls back to -# interactive `sudo -v` when run from a TTY. -log "[0/9] Caching sudo credentials" -if [ -n "${SUDO_PASS:-}" ]; then - echo "$SUDO_PASS" | sudo -S -v -else - sudo -v -fi -( while true; do sudo -n true; sleep 60; kill -0 "$$" || exit; done 2>/dev/null ) & -SUDO_KEEPER=$! -trap 'kill $SUDO_KEEPER 2>/dev/null || true' EXIT - -# --------------------------------------------------------------------------- -# Pre-flight fixups: silence sudo hostname warnings + ensure en_US.UTF-8 locale -# is generated. Both prevent failures later (postgresql cluster init refuses -# to bootstrap when LC_TIME is set to an ungenerated locale, e.g. Mac iTerm -# leaks LC_TIME=en_DK.UTF-8 over SSH). -log "[0.5/9] Pre-flight: hostname + locale" -if ! grep -q "^127\.0\.1\.1\s\+$(hostname)" /etc/hosts; then - echo "127.0.1.1 $(hostname)" | sudo tee -a /etc/hosts >/dev/null - ok "added 127.0.1.1 $(hostname) to /etc/hosts" -else - skip "hostname entry exists in /etc/hosts" -fi -if locale -a 2>/dev/null | grep -qi "en_US.utf8"; then - skip "en_US.UTF-8 locale already generated" -else - sudo apt-get install -y -qq --no-install-recommends locales - sudo locale-gen en_US.UTF-8 C.UTF-8 - sudo update-locale LANG=C.UTF-8 LC_ALL= 2>/dev/null || true - ok "en_US.UTF-8 + C.UTF-8 locales generated" -fi - -# --------------------------------------------------------------------------- -log "[1/9] Adding PostgreSQL 18 (PGDG) apt repository" -if [ -f /etc/apt/sources.list.d/pgdg.list ]; then - skip "pgdg.list present" -else - sudo install -d /usr/share/postgresql-common/pgdg - sudo curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \ - -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc - echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \ - | sudo tee /etc/apt/sources.list.d/pgdg.list >/dev/null - sudo apt-get update -qq - ok "pgdg repo added" -fi - -# --------------------------------------------------------------------------- -log "[2/9] apt install: postgresql-18 + deps (caddy installed via binary, see step 3)" -sudo apt-get install -y -qq \ - postgresql-18 postgresql-client-18 \ - rsync curl jq -ok "apt installs done" - -# --------------------------------------------------------------------------- -log "[3/9] Installing Caddy binary" -if command -v caddy >/dev/null 2>&1 && caddy version 2>/dev/null | grep -qF "${CADDY_VERSION#v}"; then - skip "caddy ${CADDY_VERSION} already installed" -elif [ -x "$PREBUILT_DIR/caddy" ]; then - sudo install -m 755 "$PREBUILT_DIR/caddy" /usr/local/bin/caddy - ok "caddy installed from prebuilt: $(caddy version | head -1)" -else - ARCH=$(dpkg --print-architecture) - TARBALL="caddy_${CADDY_VERSION#v}_linux_${ARCH}.tar.gz" - URL="https://github.com/caddyserver/caddy/releases/download/${CADDY_VERSION}/${TARBALL}" - TMP=$(mktemp -d) - echo " downloading from $URL (this may be slow from CN; consider scp'ing the binary to $PREBUILT_DIR/caddy)" - curl -fsSL --connect-timeout 10 --max-time 600 "$URL" -o "$TMP/caddy.tgz" - tar xzf "$TMP/caddy.tgz" -C "$TMP" caddy - sudo install -m 755 "$TMP/caddy" /usr/local/bin/caddy - rm -rf "$TMP" - ok "caddy installed: $(caddy version | head -1)" -fi - -if ! id caddy >/dev/null 2>&1; then - sudo groupadd --system caddy - sudo useradd --system --gid caddy \ - --home-dir /var/lib/caddy --create-home \ - --shell /usr/sbin/nologin caddy - ok "caddy user created" -else - skip "caddy user exists" -fi - -if [ ! -d /etc/caddy ]; then - sudo install -d -o root -g root -m 755 /etc/caddy - ok "/etc/caddy created" -else - skip "/etc/caddy exists" -fi - -# --------------------------------------------------------------------------- -log "[4/9] Configuring postgres (loopback only, hackforger DB+role)" -PG_CONF=/etc/postgresql/18/main/postgresql.conf - -if ! sudo grep -qE "^listen_addresses\s*=\s*'127\.0\.0\.1'" "$PG_CONF"; then - sudo sed -i "s/^#*listen_addresses\s*=.*/listen_addresses = '127.0.0.1'/" "$PG_CONF" - sudo systemctl restart postgresql - ok "PG bound to 127.0.0.1" -else - skip "listen_addresses already 127.0.0.1" -fi - -if sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='hackforger'" | grep -q 1; then - skip "PG role hackforger exists" -else - PG_PASS=$(openssl rand -base64 24 | tr -d '+/=' | cut -c1-24) - sudo -u postgres psql -c "CREATE ROLE hackforger LOGIN PASSWORD '$PG_PASS';" - sudo -u postgres psql -c "ALTER ROLE hackforger CREATEDB;" - echo "$PG_PASS" | sudo tee /root/.hackforger-pg-password >/dev/null - sudo chmod 600 /root/.hackforger-pg-password - ok "PG role hackforger created (password saved to /root/.hackforger-pg-password)" -fi - -if sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='hackforger'" | grep -q 1; then - skip "PG database hackforger exists" -else - sudo -u postgres createdb -O hackforger hackforger - ok "PG database hackforger created" -fi - -# --------------------------------------------------------------------------- -log "[5/9] Creating directories /opt/hackforger, /var/lib/hackforger, /var/lib/forgejo-runner" -for d in /opt/hackforger /var/lib/hackforger /var/lib/hackforger/data \ - /var/lib/hackforger/custom /var/lib/hackforger/custom/conf \ - /var/lib/hackforger/log /var/lib/hackforger/pg-backups \ - /var/lib/forgejo-runner /etc/forgejo-runner; do - if [ -d "$d" ]; then - skip "$d exists" - else - sudo install -d -o hackforger -g hackforger "$d" - ok "created $d" - fi -done - -if [ ! -d /var/log/caddy ]; then - sudo install -d -o caddy -g caddy /var/log/caddy - ok "created /var/log/caddy" -else - skip "/var/log/caddy exists" -fi - -sudo chown -R hackforger:hackforger /opt/hackforger /var/lib/hackforger /var/lib/forgejo-runner - -# --------------------------------------------------------------------------- -log "[6/9] Installing forgejo-runner $RUNNER_VERSION" -if command -v forgejo-runner >/dev/null && forgejo-runner --version 2>/dev/null | grep -qF "${RUNNER_VERSION#v}"; then - skip "forgejo-runner $RUNNER_VERSION already installed" -elif [ -x "$PREBUILT_DIR/forgejo-runner" ]; then - sudo install -m 755 "$PREBUILT_DIR/forgejo-runner" /usr/local/bin/forgejo-runner - ok "forgejo-runner installed from prebuilt: $(forgejo-runner --version 2>&1 | head -1)" -else - ARCH=$(dpkg --print-architecture) - if [ "$ARCH" = "amd64" ]; then RUNNER_ARCH=amd64; else RUNNER_ARCH=arm64; fi - sudo curl -fsSL --connect-timeout 10 --max-time 600 \ - "https://code.forgejo.org/forgejo/runner/releases/download/$RUNNER_VERSION/forgejo-runner-${RUNNER_VERSION#v}-linux-$RUNNER_ARCH" \ - -o /usr/local/bin/forgejo-runner - sudo chmod +x /usr/local/bin/forgejo-runner - ok "forgejo-runner installed: $(forgejo-runner --version 2>&1 | head -1)" -fi - -if [ ! -f /etc/forgejo-runner/config.yml ]; then - sudo tee /etc/forgejo-runner/config.yml >/dev/null <<'EOF' -log: - level: info -runner: - file: /var/lib/forgejo-runner/.runner - capacity: 2 - timeout: 1h - fetch_timeout: 5s - fetch_interval: 2s - labels: - - "ubuntu-latest:host" -cache: - enabled: true - dir: /var/lib/forgejo-runner/cache -EOF - sudo chown hackforger:hackforger /etc/forgejo-runner/config.yml - ok "runner config.yml installed" -else - skip "runner config.yml exists" -fi - -# --------------------------------------------------------------------------- -log "[7/9] Installing Caddyfile" -if [ -f /etc/caddy/Caddyfile ] && [ ! -f /etc/caddy/Caddyfile.bak ]; then - sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.bak -fi -sudo sed "s/__ACME_EMAIL__/$ACME_EMAIL/g" "$SCRIPT_DIR/caddy/Caddyfile.tmpl" \ - | sudo tee /etc/caddy/Caddyfile >/dev/null -ok "Caddyfile written (ACME email: $ACME_EMAIL)" - -# --------------------------------------------------------------------------- -log "[7.5/9] sudoers carve-out for hackforger to read PG password file" -SUDOERS=/etc/sudoers.d/hackforger-backup -if [ ! -f "$SUDOERS" ]; then - echo 'hackforger ALL=(root) NOPASSWD: /usr/bin/cat /root/.hackforger-pg-password' \ - | sudo tee "$SUDOERS" >/dev/null - sudo chmod 440 "$SUDOERS" - sudo visudo -cf "$SUDOERS" - ok "sudoers rule installed" -else - skip "sudoers rule exists" -fi - -# --------------------------------------------------------------------------- -log "[8/9] Installing systemd units + backup script + app.ini template + sshd drop-in" -for unit in gitea.service forgejo-runner.service caddy.service \ - hackforger-backup.service hackforger-backup.timer; do - sudo cp "$SCRIPT_DIR/systemd/$unit" "/etc/systemd/system/$unit" -done -sudo cp "$SCRIPT_DIR/backup-ecs.sh" /usr/local/bin/hackforger-backup.sh -sudo chmod +x /usr/local/bin/hackforger-backup.sh -sudo cp "$SCRIPT_DIR/app.ini.tmpl" /opt/hackforger/app.ini.tmpl -sudo chown hackforger:hackforger /opt/hackforger/app.ini.tmpl - -# sshd drop-in: makes sshd read both /home/hackforger/.ssh/authorized_keys (operator -# shell-login keys) and /var/lib/hackforger/.ssh/authorized_keys (Forgejo-managed -# keys with command="gitea serv ..." prefix). Required because the gitea systemd -# unit overrides HOME=/var/lib/hackforger + ProtectHome=yes, so Forgejo writes -# authorized_keys outside the OS user's home where sshd looks by default. -if [ -f "$SCRIPT_DIR/sshd-99-hackforger.conf" ]; then - sudo install -m 644 -o root -g root "$SCRIPT_DIR/sshd-99-hackforger.conf" \ - /etc/ssh/sshd_config.d/99-hackforger.conf - if sudo sshd -t; then - sudo systemctl reload ssh - ok "sshd drop-in installed and ssh reloaded" - else - echo " WARN: sshd -t failed after installing drop-in — removed it" >&2 - sudo rm /etc/ssh/sshd_config.d/99-hackforger.conf - fi -else - echo " skip: $SCRIPT_DIR/sshd-99-hackforger.conf not present (older bootstrap?)" -fi - -sudo systemctl daemon-reload -sudo systemctl enable hackforger-backup.timer >/dev/null 2>&1 || true -sudo systemctl start hackforger-backup.timer -sudo systemctl enable caddy >/dev/null 2>&1 || true -ok "systemd units installed (gitea + runner enabled but NOT started — start after data migration)" - -# --------------------------------------------------------------------------- -log "[9/9] Summary" -echo " Postgres: $(systemctl is-active postgresql)" -echo " Caddy: $(systemctl is-active caddy)" -echo " Gitea: NOT STARTED (start after migrate-data.sh)" -echo " Runner: NOT STARTED (start after registering with gitea admin token)" -echo " Backup timer: $(systemctl is-active hackforger-backup.timer)" -echo -echo " PG password: /root/.hackforger-pg-password" -echo " Custom path: /var/lib/hackforger/custom" -echo " Binary path: /opt/hackforger/gitea (NOT YET PRESENT — scp it next)" -echo -echo "Next steps:" -echo " 1. From Mac: bash deploy/ecs/build-linux.sh" -echo " 2. From Mac: scp gitea-linux-amd64 hackforger@203.119.115.130:/opt/hackforger/gitea" -echo " 3. From Mac: bash deploy/ecs/migrate-data.sh --confirm" -echo " 4. On ECS: sudo systemctl start gitea caddy" -ok "Bootstrap complete" diff --git a/deploy/ecs/install-sshd-config.sh b/deploy/ecs/install-sshd-config.sh deleted file mode 100755 index 45f692c266..0000000000 --- a/deploy/ecs/install-sshd-config.sh +++ /dev/null @@ -1,127 +0,0 @@ -#!/bin/bash -# Install (or remove) the HackForger sshd drop-in on prod ECS. -# -# Background: enabling Forgejo SSH (DISABLE_SSH=false) requires sshd to also -# read /var/lib/hackforger/.ssh/authorized_keys (where Forgejo writes, because -# the gitea systemd unit overrides HOME and uses ProtectHome=yes). This script -# scp's deploy/ecs/sshd-99-hackforger.conf to /etc/ssh/sshd_config.d/, validates -# with `sshd -t`, then `systemctl reload ssh`. Reload preserves existing -# connections, so a bad config does not lock out the current ssh session. -# -# Usage: -# bash deploy/ecs/install-sshd-config.sh # install or update -# bash deploy/ecs/install-sshd-config.sh --rollback # remove + reload -# -# Inputs: .env on Mac with ECS_SUDO_PASS=... - -set -euo pipefail - -REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -cd "$REPO" - -ECS=hackforger@203.119.115.130 -CONF_LOCAL="deploy/ecs/sshd-99-hackforger.conf" -CONF_REMOTE_DEST="/etc/ssh/sshd_config.d/99-hackforger.conf" - -ROLLBACK=0 -case "${1:-}" in - --rollback) ROLLBACK=1 ;; - "") ;; - *) echo "Usage: $0 [--rollback]" >&2; exit 64 ;; -esac - -if ! grep -q "^ECS_SUDO_PASS=" .env; then - echo "FATAL: ECS_SUDO_PASS not in .env" >&2 - exit 1 -fi -SUDO_PASS=$(grep "^ECS_SUDO_PASS=" .env | cut -d= -f2-) - -log() { printf "\n\033[1;34m▶ %s\033[0m\n" "$*"; } -ok() { printf " \033[32m✓\033[0m %s\n" "$*"; } - -if [ "$ROLLBACK" = "1" ]; then - log "Rollback: remove $CONF_REMOTE_DEST + reload sshd" - ssh "$ECS" "SUDO_PASS='$SUDO_PASS' DEST='$CONF_REMOTE_DEST' bash -s" <<'REMOTE' -set -euo pipefail -echo "$SUDO_PASS" | sudo -S -v -( while true; do sudo -n true; sleep 60; kill -0 "$$" || exit; done 2>/dev/null ) & -KEEPER=$!; trap 'kill $KEEPER 2>/dev/null || true' EXIT - -if [ -f "$DEST" ]; then - sudo rm "$DEST" - if sudo sshd -t; then - sudo systemctl reload ssh - echo " ✓ removed and sshd reloaded" - else - echo " ✗ sshd -t FAILED after removal — leaving sshd as-is" >&2 - exit 1 - fi -else - echo " (file did not exist; nothing to do)" -fi -REMOTE - exit 0 -fi - -# Install path -test -f "$CONF_LOCAL" || { echo "FATAL: $CONF_LOCAL not found" >&2; exit 1; } - -log "[1/3] scp $CONF_LOCAL → $ECS:/tmp/" -TMP_REMOTE="/tmp/sshd-99-hackforger.conf.$$" -scp -q "$CONF_LOCAL" "$ECS:$TMP_REMOTE" -ok "staged at $TMP_REMOTE" - -log "[2/3] sudo install + sshd -t + reload (preserves current connections)" -ssh "$ECS" "SUDO_PASS='$SUDO_PASS' TMP='$TMP_REMOTE' DEST='$CONF_REMOTE_DEST' bash -s" <<'REMOTE' -set -euo pipefail -echo "$SUDO_PASS" | sudo -S -v -( while true; do sudo -n true; sleep 60; kill -0 "$$" || exit; done 2>/dev/null ) & -KEEPER=$!; trap 'kill $KEEPER 2>/dev/null || true' EXIT - -# Backup if existing -if [ -f "$DEST" ]; then - BAK="$DEST.bak.$(date +%Y%m%d-%H%M%S)" - sudo cp -p "$DEST" "$BAK" - echo " backed up existing → $BAK" -fi - -# Install (644 root:root is sshd_config.d standard) -sudo install -m 644 -o root -g root "$TMP" "$DEST" -sudo rm -f "$TMP" -echo " installed → $DEST" - -# Validate before reload -if sudo sshd -t; then - echo " ✓ sshd -t passed" -else - echo " ✗ sshd -t FAILED — removing $DEST and aborting (sshd not reloaded)" >&2 - sudo rm "$DEST" - exit 1 -fi - -# Reload (preserves existing connections; bad config = reload fails but old config keeps serving) -sudo systemctl reload ssh -sleep 1 -sudo systemctl status ssh --no-pager -n 0 | head -3 - -echo -echo " effective AuthorizedKeysFile for hackforger user:" -sudo sshd -T -C "user=hackforger,host=,addr=" 2>/dev/null | grep -i '^authorizedkeysfile' | sed 's/^/ /' -REMOTE - -log "[3/3] Verify by opening a fresh ssh connection" -if ssh -o ConnectTimeout=8 -o BatchMode=yes "$ECS" 'echo POST_RELOAD_OK' >/tmp/.sshd-install-verify.$$ 2>&1; then - cat /tmp/.sshd-install-verify.$$ - ok "fresh ssh login still works" -else - echo " ⚠ fresh ssh login FAILED. Output:" >&2 - cat /tmp/.sshd-install-verify.$$ >&2 - echo " ⚠ This may NOT be a real failure if the SSH agent didn't offer the right key." >&2 - echo " ⚠ Try manually: ssh hackforger@203.119.115.130 echo OK" >&2 - echo " ⚠ If it really is broken, rollback with:" >&2 - echo " bash deploy/ecs/install-sshd-config.sh --rollback" >&2 -fi -rm -f /tmp/.sshd-install-verify.$$ - -echo -ok "Install complete." diff --git a/deploy/ecs/launchd/com.h2os.hackforger-backup-pull.plist b/deploy/ecs/launchd/com.h2os.hackforger-backup-pull.plist deleted file mode 100644 index c87f3f6654..0000000000 --- a/deploy/ecs/launchd/com.h2os.hackforger-backup-pull.plist +++ /dev/null @@ -1,31 +0,0 @@ - - - - - Label - com.h2os.hackforger-backup-pull - - ProgramArguments - - /bin/bash - /Users/h2oslabs/Workspace/hackforger/scripts/backup-pull.sh - - - StartCalendarInterval - - Hour - 4 - Minute - 30 - - - StandardOutPath - /Users/h2oslabs/Backups/hackforger/launchd.log - - StandardErrorPath - /Users/h2oslabs/Backups/hackforger/launchd.log - - RunAtLoad - - - diff --git a/deploy/ecs/migrate-data.sh b/deploy/ecs/migrate-data.sh deleted file mode 100755 index 2b8ace7e88..0000000000 --- a/deploy/ecs/migrate-data.sh +++ /dev/null @@ -1,159 +0,0 @@ -#!/bin/bash -# Mac-side: dump the post-clean-slate prod DB and data dirs, rsync to the ECS, -# trigger restore via SSH. -# -# Idempotent for the dump+rsync side. The restore side OVERWRITES the ECS DB -# and data dirs, so guard with --confirm. -# -# Usage: -# bash deploy/ecs/migrate-data.sh --dry-run # dump + rsync only, no restore -# bash deploy/ecs/migrate-data.sh --confirm # full migration - -set -euo pipefail - -DRY_RUN=0 -CONFIRM=0 -for arg in "$@"; do - case "$arg" in - --dry-run) DRY_RUN=1 ;; - --confirm) CONFIRM=1 ;; - *) echo "Usage: $0 (--dry-run|--confirm)" >&2; exit 64 ;; - esac -done -if [ "$DRY_RUN$CONFIRM" = "00" ]; then - echo "FATAL: pass --dry-run or --confirm" >&2 - exit 64 -fi - -REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -cd "$REPO" - -ECS=hackforger@203.119.115.130 -TMP_DUMP=/tmp/hackforger-prod.pgc -TMP_TAR=/tmp/hackforger-data.tgz - -log() { printf "\n\033[1;34m▶ %s\033[0m\n" "$*"; } - -# --------------------------------------------------------------------------- -log "[1/6] Reading PG password from .env" -if ! grep -q "^PGPASSWORD=" .env; then - echo "FATAL: PGPASSWORD not in .env" >&2 - exit 1 -fi -PG_PASS=$(grep "^PGPASSWORD=" .env | cut -d= -f2) -export PGPASSWORD="$PG_PASS" - -# --------------------------------------------------------------------------- -log "[2/6] Stop Mac gitea (if running) — only with --confirm" -if [ "$CONFIRM" = "1" ]; then - PID=$(lsof -iTCP:3000 -sTCP:LISTEN -t 2>/dev/null || true) - if [ -n "$PID" ]; then - echo " Stopping gitea PID $PID" - kill "$PID" - for _ in 1 2 3 4 5 6 7 8 9 10; do - lsof -iTCP:3000 -sTCP:LISTEN -t >/dev/null 2>&1 || break - sleep 1 - done - else - echo " gitea not running on :3000" - fi -else - echo " --dry-run: leaving Mac gitea running" -fi - -# --------------------------------------------------------------------------- -log "[3/6] pg_dump → $TMP_DUMP" -pg_dump -Fc -h 127.0.0.1 -U hackforger hackforger > "$TMP_DUMP" -ls -lh "$TMP_DUMP" - -# --------------------------------------------------------------------------- -log "[4/6] tar data dirs + custom assets → $TMP_TAR" -TAR_ITEMS=(data/forgejo-repositories data/attachments data/avatars - custom/conf/app.ini custom/public custom/templates) -[ -d data/lfs ] && TAR_ITEMS+=(data/lfs) -tar czf "$TMP_TAR" -C "$REPO" "${TAR_ITEMS[@]}" 2>/dev/null -ls -lh "$TMP_TAR" - -# --------------------------------------------------------------------------- -log "[5/6] rsync to ECS" -rsync -avz --progress "$TMP_DUMP" "$TMP_TAR" "$ECS:/tmp/" - -# --------------------------------------------------------------------------- -log "[6/6] Trigger restore on ECS" -if [ "$CONFIRM" = "1" ]; then - # Carry over the Mac's INTERNAL_TOKEN and JWT_SECRET verbatim — they MUST - # NOT change across migration (would invalidate sessions + break runner). - INTERNAL_TOKEN=$(grep "^INTERNAL_TOKEN" custom/conf/app.ini | cut -d= -f2- | xargs) - OAUTH_JWT=$(grep "^JWT_SECRET" custom/conf/app.ini | cut -d= -f2- | xargs) - - if [ -z "$INTERNAL_TOKEN" ] || [ -z "$OAUTH_JWT" ]; then - echo "FATAL: failed to read INTERNAL_TOKEN or JWT_SECRET from custom/conf/app.ini" >&2 - exit 2 - fi - - # Read the SSH-side sudo password from the same .env that ecs-bootstrap.sh - # uses. The remote restore needs sudo (drop/create DB, chown, install file), - # but the ssh session has no TTY so it can't prompt — feed via stdin once. - if ! grep -q "^ECS_SUDO_PASS=" .env; then - echo "FATAL: ECS_SUDO_PASS not in .env (the box's hackforger sudo password)" >&2 - exit 2 - fi - SUDO_PASS=$(grep "^ECS_SUDO_PASS=" .env | cut -d= -f2-) - - ssh "$ECS" \ - INTERNAL_TOKEN="'$INTERNAL_TOKEN'" \ - OAUTH_JWT="'$OAUTH_JWT'" \ - SUDO_PASS="'$SUDO_PASS'" \ - bash -s <<'REMOTE_EOF' -set -euo pipefail - -log() { printf "\n\033[1;34m ▶ %s\033[0m\n" "$*"; } - -# Prime sudo from stdin once; keeper loop renews credentials every 60s. -echo "$SUDO_PASS" | sudo -S -v -( while true; do sudo -n true; sleep 60; kill -0 "$$" || exit; done 2>/dev/null ) & -SUDO_KEEPER=$! -trap 'kill $SUDO_KEEPER 2>/dev/null || true' EXIT - -log "Stopping gitea + runner (if running)" -sudo systemctl stop gitea forgejo-runner 2>/dev/null || true - -log "Dropping & recreating PG database hackforger" -sudo -u postgres dropdb --if-exists hackforger -sudo -u postgres createdb -O hackforger hackforger - -log "pg_restore" -PGPASSWORD=$(sudo cat /root/.hackforger-pg-password) \ - pg_restore -h 127.0.0.1 -U hackforger -d hackforger /tmp/hackforger-prod.pgc - -log "Extracting data tar to /var/lib/hackforger" -sudo -u hackforger mkdir -p /var/lib/hackforger -sudo tar xzf /tmp/hackforger-data.tgz -C /var/lib/hackforger -sudo chown -R hackforger:hackforger /var/lib/hackforger - -log "Rendering app.ini from template" -PG_PASS=$(sudo cat /root/.hackforger-pg-password) -# Use a tmp file to avoid sed escaping pain with passwords -TMP_INI=$(mktemp) -sed \ - -e "s|__PG_PASSWORD__|$PG_PASS|g" \ - -e "s|__INTERNAL_TOKEN__|$INTERNAL_TOKEN|g" \ - -e "s|__OAUTH2_JWT_SECRET__|$OAUTH_JWT|g" \ - /opt/hackforger/app.ini.tmpl > "$TMP_INI" -sudo install -o hackforger -g hackforger -m 640 "$TMP_INI" \ - /var/lib/hackforger/custom/conf/app.ini -rm -f "$TMP_INI" - -log "Smoke-test: gitea --version" -sudo -u hackforger /opt/hackforger/gitea --version || { - echo "gitea binary missing or not executable" >&2 - exit 2 -} -echo " ✓ Restore complete. Next: sudo systemctl start gitea caddy" -REMOTE_EOF -else - echo " --dry-run: skipping restore step" - echo " Files staged on ECS at: /tmp/hackforger-prod.pgc, /tmp/hackforger-data.tgz" -fi - -log "Done" diff --git a/deploy/ecs/preflight.sh b/deploy/ecs/preflight.sh deleted file mode 100755 index a7e66401a9..0000000000 --- a/deploy/ecs/preflight.sh +++ /dev/null @@ -1,161 +0,0 @@ -#!/bin/bash -# Pre-deploy gate: refuse to ship commits that don't have a smoke-test report. -# -# Usage: bash deploy/ecs/preflight.sh -# Inputs: reads .last-deploy SHA from the ECS via SSH -# Outputs: exit 0 + "ready to deploy: " on success -# exit 1 + per-commit failure listing on failure -# -# See: docs/notes/gitflow.md for the full process. - -set -euo pipefail - -REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -cd "$REPO" - -ECS=synnovator-admin # ssh alias from ~/.ssh/config — uses ops-only key. plain hackforger@203.x routes through Forgejo SSH after #160. -REPORTS_DIR=docs/tests/e2e/reports -PROD_BRANCH="${PROD_BRANCH:-prod}" - -# Paths that DON'T need a smoke-test report (infra/docs/tooling only). -# `.claude/` covers hookify rules, agent prompts, plugin configs etc — they -# affect tooling for future sessions, not what end users see in the running app. -INFRA_PATHS_RE='^(deploy/|scripts/|docs/|\.github/|\.forgejo/|\.claude/|\.gitignore$|\.editorconfig$|Makefile$|CHANGELOG\.md$|[^/]+\.md$|go\.mod$|go\.sum$|package-lock\.json$|package\.json$)' - -log() { printf "\n\033[1;34m▶ %s\033[0m\n" "$*"; } -fail() { printf " \033[31m✗\033[0m %s\n" "$*"; } -ok() { printf " \033[32m✓\033[0m %s\n" "$*"; } - -# --------------------------------------------------------------------------- -log "Checking prod branch" -if ! git rev-parse --verify "$PROD_BRANCH" >/dev/null 2>&1; then - echo "FATAL: branch '$PROD_BRANCH' does not exist locally." >&2 - echo " First time? Create it from the desired baseline:" >&2 - echo " git branch $PROD_BRANCH origin/v0.1-dev/hackforger" >&2 - exit 2 -fi -PROD_HEAD=$(git rev-parse "$PROD_BRANCH") -echo " $PROD_BRANCH HEAD: $PROD_HEAD" - -# --------------------------------------------------------------------------- -log "Reading last-deploy marker from $ECS" -LAST_DEPLOY=$(ssh -o ConnectTimeout=10 "$ECS" \ - 'cat /var/lib/hackforger/.last-deploy 2>/dev/null || echo MISSING' | tr -d '[:space:]') -if [ "$LAST_DEPLOY" = "MISSING" ]; then - echo " no .last-deploy file on ECS — treating as bootstrap (must verify ALL commits on $PROD_BRANCH)" - RANGE="$PROD_BRANCH" -else - echo " last deployed: $LAST_DEPLOY" - RANGE="${LAST_DEPLOY}..${PROD_BRANCH}" -fi - -# --------------------------------------------------------------------------- -log "Commits to verify" -COMMITS=$(git rev-list --reverse "$RANGE" 2>/dev/null || true) -if [ -z "$COMMITS" ]; then - ok "no new commits to deploy — already up to date" - exit 0 -fi -COUNT=$(echo "$COMMITS" | wc -l | tr -d ' ') -echo " $COUNT commit(s) in $RANGE" - -# --------------------------------------------------------------------------- -log "Per-commit smoke-test check" -FAIL=0 -PASS=0 -SKIP=0 - -for sha in $COMMITS; do - short=$(git rev-parse --short "$sha") - subject=$(git log -1 --format='%s' "$sha") - files=$(git show --name-only --format= "$sha") - - # Is this commit infra-only? - user_facing=0 - for f in $files; do - [ -z "$f" ] && continue - if ! echo "$f" | grep -qE "$INFRA_PATHS_RE"; then - user_facing=1 - break - fi - done - - if [ "$user_facing" = "0" ]; then - printf " \033[33m·\033[0m %s %s — INFRA-ONLY (skip)\n" "$short" "${subject:0:60}" - SKIP=$((SKIP+1)) - continue - fi - - # Look for a report referencing this SHA in frontmatter - # `|| true` because grep returns 1 when no match; combined with set -euo - # pipefail this would silently kill the whole script after the first - # user-facing commit without a report. We want to record the failure and - # continue so the operator sees ALL missing reports in one run. - report=$(grep -lE "^\s*-\s+${sha}\b|^\s*-\s+${short}\b" "$REPORTS_DIR"/*.md 2>/dev/null | head -1 || true) - if [ -z "$report" ]; then - fail "$short $subject" - echo " no report references $sha (or $short) in $REPORTS_DIR/" - FAIL=$((FAIL+1)) - continue - fi - - # Check admin_signoff: non-null AND not empty. - # YAML allows two forms: - # admin_signoff: null ← unsigned, FAIL - # admin_signoff: ~ ← unsigned, FAIL - # admin_signoff: ← unsigned (empty value), FAIL - # admin_signoff:\n by: alice... ← signed (multi-line mapping), PASS - # admin_signoff: { by: alice, ... } ← signed (inline mapping), PASS - # Detection: extract the value after the colon. If it's empty, null, or ~, - # check the line after — if that line is indented and looks like YAML mapping - # continuation (e.g., " by:"), treat as signed; else unsigned. - signoff_check=$(awk ' - /^admin_signoff:/ { - val = $0 - sub(/^admin_signoff:[ \t]*/, "", val) - if (val == "" || val == "null" || val == "~") { - # Look at next line for mapping continuation - if ((getline next_line) > 0 && next_line ~ /^[ \t]+[A-Za-z_][A-Za-z0-9_-]*[ \t]*:/) { - print "SIGNED" - } else { - print "UNSIGNED" - } - } else { - print "SIGNED" - } - exit - } - ' "$report") - if [ "$signoff_check" = "UNSIGNED" ]; then - fail "$short $subject" - echo " report exists ($report) but admin_signoff is null/empty" - FAIL=$((FAIL+1)) - continue - fi - - ok "$short $subject ($(basename "$report"))" - PASS=$((PASS+1)) -done - -# --------------------------------------------------------------------------- -echo -echo "summary: $PASS pass, $FAIL fail, $SKIP infra-only-skip" -if [ "$FAIL" -gt 0 ]; then - echo - echo "DEPLOY BLOCKED. Each failing commit needs a report at:" - echo " docs/tests/e2e/reports/YYYY-MM-DD-.md" - echo "with frontmatter:" - echo " commits: []" - echo " admin_signoff: { by: , at: }" - echo - echo "See docs/notes/gitflow.md for the full process." - exit 1 -fi - -echo -echo "READY TO DEPLOY: $PROD_HEAD" -echo -echo "Next:" -echo " bash deploy/ecs/build-linux.sh" -echo " scp gitea-linux-amd64 $ECS:/opt/hackforger/gitea-new" -echo " ssh $ECS 'sudo install -m 755 -o hackforger -g hackforger /opt/hackforger/gitea-new /opt/hackforger/gitea && sudo systemctl restart gitea && echo $PROD_HEAD | sudo tee /var/lib/hackforger/.last-deploy'" diff --git a/deploy/ecs/redeploy.sh b/deploy/ecs/redeploy.sh deleted file mode 100755 index a43d21516a..0000000000 --- a/deploy/ecs/redeploy.sh +++ /dev/null @@ -1,168 +0,0 @@ -#!/bin/bash -# Atomic redeploy of HackForger to the production ECS. -# -# Build (or reuse existing) gitea-linux-amd64, sync custom/templates/ and -# non-landing custom/public/ overrides, install the binary, restart gitea, record -# .last-deploy. The externally managed landing subtree is never synced here. -# -# This is the script you run AFTER the initial migrate-data.sh — for every -# subsequent change. Use it instead of ad-hoc scp + restart, because: -# -# - It rsyncs custom/templates/ and non-landing custom/public/ overrides alongside -# the binary while protecting the externally managed landing subtree. -# Forgetting these is a real bug we hit: PRs that only touch templates -# or tracked public assets won't take effect from a binary-only deploy. -# (Locale .ini files ARE in the binary via bindata, so they don't need -# rsync — but template files do, since custom/ overrides bindata at -# runtime.) -# -# - It updates /var/lib/hackforger/.last-deploy so deploy/ecs/preflight.sh -# knows what was shipped. -# -# - It uses a single sudo invocation per remote step, so it works in -# non-TTY ssh sessions (just like ecs-bootstrap.sh). -# -# Usage: -# bash deploy/ecs/redeploy.sh [--skip-build] [--skip-preflight] -# -# Inputs: -# .env on Mac: ECS_SUDO_PASS=... (the box's hackforger sudo password) -# git HEAD: used to record .last-deploy on the box - -set -euo pipefail - -REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -cd "$REPO" - -ECS=synnovator-admin # ssh alias from ~/.ssh/config — uses ops-only key. plain hackforger@203.x routes through Forgejo SSH after #160. -SKIP_BUILD=0 -SKIP_PREFLIGHT=0 -for arg in "$@"; do - case "$arg" in - --skip-build) SKIP_BUILD=1 ;; - --skip-preflight) SKIP_PREFLIGHT=1 ;; - *) echo "Usage: $0 [--skip-build] [--skip-preflight]" >&2; exit 64 ;; - esac -done - -log() { printf "\n\033[1;34m▶ %s\033[0m\n" "$*"; } -ok() { printf " \033[32m✓\033[0m %s\n" "$*"; } - -# --------------------------------------------------------------------------- -log "[0/7] Reading sudo password from .env" -if ! grep -q "^ECS_SUDO_PASS=" .env; then - echo "FATAL: ECS_SUDO_PASS not in .env" >&2 - exit 1 -fi -SUDO_PASS=$(grep "^ECS_SUDO_PASS=" .env | cut -d= -f2-) - -# --------------------------------------------------------------------------- -if [ "$SKIP_PREFLIGHT" = "0" ]; then - log "[1/7] Preflight (use --skip-preflight to bypass for hotfixes)" - if [ -x deploy/ecs/preflight.sh ]; then - bash deploy/ecs/preflight.sh - else - echo " WARN: deploy/ecs/preflight.sh not present — gate not yet merged" - echo " skipping (this is OK for early deploys; merge the gitflow PR to enforce)" - fi -else - log "[1/7] Preflight SKIPPED (--skip-preflight)" -fi - -# --------------------------------------------------------------------------- -log "[2/7] Build linux binary" -if [ "$SKIP_BUILD" = "1" ] && [ -f gitea-linux-amd64 ]; then - ok "reusing existing gitea-linux-amd64 ($(ls -lh gitea-linux-amd64 | awk '{print $5}'))" -else - bash deploy/ecs/build-linux.sh -fi - -# --------------------------------------------------------------------------- -log "[3/7] Sync custom/templates + non-landing custom/public to ECS" -# These override bindata-embedded templates at runtime. A binary-only deploy -# is a bug — locale .ini files are in the binary, but template/asset -# overrides are in the filesystem. -# -# DELIBERATELY excluded: -# custom/conf/app.ini (per-instance config; renders on bootstrap, never sync) -# custom/options/ (locale overrides; if used, add separately) -# custom/public/assets/landing/ (external source; must survive --delete) -rsync -az --delete custom/templates/ "$ECS:/tmp/custom-templates-staged/" -rsync -az --delete --exclude '/assets/landing/' \ - custom/public/ "$ECS:/tmp/custom-public-staged/" -ok "staged at /tmp/custom-{templates,public}-staged/" - -# --------------------------------------------------------------------------- -log "[4/7] Stage binary on ECS" -scp -q gitea-linux-amd64 "$ECS:/tmp/gitea-new" -ok "staged at /tmp/gitea-new" - -# --------------------------------------------------------------------------- -log "[5/7] Atomic swap + restart (single ssh, single sudo)" -SHA=$(git rev-parse HEAD) -ssh "$ECS" "SUDO_PASS='$SUDO_PASS' SHA='$SHA' bash -s" <<'REMOTE_EOF' -set -euo pipefail -echo "$SUDO_PASS" | sudo -S -v -( while true; do sudo -n true; sleep 60; kill -0 "$$" || exit; done 2>/dev/null ) & -KEEPER=$! -trap 'kill $KEEPER 2>/dev/null || true' EXIT - -# Templates + non-landing public assets. The landing tree is no longer in git, so -# require the deployed source to exist and protect it from receiver-side delete. -LANDING_DIR=/var/lib/hackforger/custom/public/assets/landing -if ! sudo test -s "$LANDING_DIR/index.html"; then - echo "FATAL: managed landing source is missing; refusing destructive sync" >&2 - exit 1 -fi -sudo rsync -a --delete /tmp/custom-templates-staged/ /var/lib/hackforger/custom/templates/ -sudo rsync -a --delete --exclude '/assets/landing/' \ - /tmp/custom-public-staged/ /var/lib/hackforger/custom/public/ -sudo test -s "$LANDING_DIR/index.html" -sudo chown -R hackforger:hackforger /var/lib/hackforger/custom/templates /var/lib/hackforger/custom/public -sudo rm -rf /tmp/custom-templates-staged /tmp/custom-public-staged - -# Binary swap (install is atomic — old binary keeps serving until restart) -sudo install -m 755 -o hackforger -g hackforger /tmp/gitea-new /opt/hackforger/gitea -sudo rm -f /tmp/gitea-new - -# Restart -sudo systemctl restart gitea - -# Record what we shipped -echo "$SHA" | sudo tee /var/lib/hackforger/.last-deploy >/dev/null - -echo " ✓ binary + custom/ swapped, gitea restarting" -REMOTE_EOF - -# --------------------------------------------------------------------------- -log "[6/7] Wait for gitea to come back" -for _ in $(seq 1 30); do - if ssh "$ECS" 'curl -fsS http://127.0.0.1:3000/api/v1/version' 2>/dev/null > /dev/null; then - ok "gitea responding" - break - fi - sleep 2 -done - -# --------------------------------------------------------------------------- -log "[7/7] Public smoke tests" -echo -n " /api/v1/version → " -curl -fsS https://www.synnovator.com/api/v1/version -echo - -echo -n " navbar zh-CN '登录/注册' present → " -if curl -fsS -H "Accept-Language: zh-CN,zh;q=0.9" "https://www.synnovator.com/explore/repos" \ - | grep -qE '登录\s?/\s?注册'; then - ok "yes" -else - echo "NOT FOUND (template may not have synced)" -fi - -echo -n " HTTPS landing returns 200 → " -status=$(curl -sI https://www.synnovator.com/ 2>&1 | grep -oE 'HTTP/[0-9.]+ [0-9]+' | tail -1) -echo "$status" - -echo -ok "Deploy complete: SHA $SHA" -echo " cloud .last-deploy:" -ssh "$ECS" 'cat /var/lib/hackforger/.last-deploy' diff --git a/deploy/ecs/sshd-99-hackforger.conf b/deploy/ecs/sshd-99-hackforger.conf deleted file mode 100644 index 7c250295f7..0000000000 --- a/deploy/ecs/sshd-99-hackforger.conf +++ /dev/null @@ -1,14 +0,0 @@ -# HackForger: also read Forgejo-managed authorized_keys -# -# Forgejo writes user-uploaded SSH keys to ~/.ssh/authorized_keys with a -# command="gitea serv key-N" prefix that turns sshd connections into -# git-only sessions. But the gitea systemd unit (deploy/ecs/systemd/gitea.service) -# sets Environment=HOME=/var/lib/hackforger and ProtectHome=yes, so Forgejo's -# managed file lands at /var/lib/hackforger/.ssh/authorized_keys — which sshd -# does not consult by default (it reads the OS home, /home/hackforger). -# -# This Match block tells sshd to also check the Forgejo-managed file for the -# hackforger user, while keeping the operator shell-login keys in /home/hackforger -# working as before. -Match User hackforger - AuthorizedKeysFile /home/hackforger/.ssh/authorized_keys /var/lib/hackforger/.ssh/authorized_keys diff --git a/deploy/ecs/systemd/caddy.service b/deploy/ecs/systemd/caddy.service deleted file mode 100644 index 5fc9c67719..0000000000 --- a/deploy/ecs/systemd/caddy.service +++ /dev/null @@ -1,21 +0,0 @@ -[Unit] -Description=Caddy web server -Documentation=https://caddyserver.com/docs/ -After=network.target network-online.target -Requires=network-online.target - -[Service] -Type=notify -User=caddy -Group=caddy -ExecStart=/usr/local/bin/caddy run --environ --config /etc/caddy/Caddyfile -ExecReload=/usr/local/bin/caddy reload --config /etc/caddy/Caddyfile --force -TimeoutStopSec=5s -LimitNOFILE=1048576 -PrivateTmp=true -ProtectSystem=full -AmbientCapabilities=CAP_NET_BIND_SERVICE -ReadWritePaths=/var/lib/caddy /var/log/caddy - -[Install] -WantedBy=multi-user.target diff --git a/deploy/ecs/systemd/forgejo-runner.service b/deploy/ecs/systemd/forgejo-runner.service deleted file mode 100644 index 9caa1b2278..0000000000 --- a/deploy/ecs/systemd/forgejo-runner.service +++ /dev/null @@ -1,23 +0,0 @@ -[Unit] -Description=Forgejo Actions runner for HackForger -After=network.target gitea.service -Wants=gitea.service - -[Service] -Type=simple -User=hackforger -Group=hackforger -WorkingDirectory=/var/lib/forgejo-runner -ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo-runner/config.yml -Restart=on-failure -RestartSec=5s - -# Hardening -NoNewPrivileges=yes -ProtectSystem=strict -ProtectHome=yes -ReadWritePaths=/var/lib/forgejo-runner /tmp -PrivateTmp=yes - -[Install] -WantedBy=multi-user.target diff --git a/deploy/ecs/systemd/gitea.service b/deploy/ecs/systemd/gitea.service deleted file mode 100644 index 502dcbcf67..0000000000 --- a/deploy/ecs/systemd/gitea.service +++ /dev/null @@ -1,28 +0,0 @@ -[Unit] -Description=HackForger (gitea fork) -After=network.target postgresql.service -Wants=postgresql.service - -[Service] -Type=simple -User=hackforger -Group=hackforger -WorkingDirectory=/var/lib/hackforger -ExecStart=/opt/hackforger/gitea web --custom-path /var/lib/hackforger/custom --work-path /var/lib/hackforger -Restart=on-failure -RestartSec=5s -LimitNOFILE=65536 -Environment=USER=hackforger HOME=/var/lib/hackforger - -# Hardening -NoNewPrivileges=yes -ProtectSystem=strict -ProtectHome=yes -ReadWritePaths=/var/lib/hackforger /var/log -PrivateTmp=yes -ProtectKernelTunables=yes -ProtectKernelModules=yes -ProtectControlGroups=yes - -[Install] -WantedBy=multi-user.target diff --git a/deploy/ecs/systemd/hackforger-backup.service b/deploy/ecs/systemd/hackforger-backup.service deleted file mode 100644 index f4ac1e3ac3..0000000000 --- a/deploy/ecs/systemd/hackforger-backup.service +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=HackForger nightly DB dump -After=postgresql.service - -[Service] -Type=oneshot -User=hackforger -Group=hackforger -ExecStart=/usr/local/bin/hackforger-backup.sh -StandardOutput=journal -StandardError=journal diff --git a/deploy/ecs/systemd/hackforger-backup.timer b/deploy/ecs/systemd/hackforger-backup.timer deleted file mode 100644 index 8ab7871c9b..0000000000 --- a/deploy/ecs/systemd/hackforger-backup.timer +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=Run HackForger backup daily at 04:00 Asia/Shanghai - -[Timer] -OnCalendar=*-*-* 04:00:00 Asia/Shanghai -Persistent=true -RandomizedDelaySec=120s -Unit=hackforger-backup.service - -[Install] -WantedBy=timers.target diff --git a/docs/dev-setup.md b/docs/dev-setup.md index cf5611084e..23d328d9bf 100644 --- a/docs/dev-setup.md +++ b/docs/dev-setup.md @@ -1,662 +1,34 @@ -# HackForger 开发环境配置 +# HackForger development setup -> HackForger = Fork of Forgejo + HackForger 模块(Hackathon / Bounty / Grant / Credits / Feed) -> 上游仓库: https://codeberg.org/forgejo/forgejo -> 项目仓库: https://github.com/HackForger/hackforger -> 内网实例: https://hackforger.inside.h2os.cloud -> 主力开发工具: Claude Code +HackForger is a Forgejo fork. Keep local and instance-specific configuration outside the public Git tree. ---- - -## 一、代码 Fork 与仓库配置 - -### 1.1 初始 Fork(在 GitHub 上操作) - -```bash -# 方式 A:通过 GitHub Web UI Fork -# 访问 https://codeberg.org/forgejo/forgejo → 在本地 clone 后推送到 GitHub - -# 方式 B:通过命令行 -git clone https://codeberg.org/forgejo/forgejo.git hackforger -cd hackforger - -# 查看最新稳定 tag -git tag --sort=-v:refname | head -5 - -# 基于最新稳定版创建主分支 -git checkout -b main v12.0.1 # 替换为实际最新 tag - -# 配置 remote -git remote rename origin upstream -git remote add origin git@github.com:HackForger/hackforger.git - -# 推送 -git push -u origin main -``` - -### 1.2 Remote 配置 - -```bash -git remote -v -# origin git@github.com:HackForger/hackforger.git (fetch) -# origin git@github.com:HackForger/hackforger.git (push) -# upstream https://codeberg.org/forgejo/forgejo.git (fetch) -# upstream https://codeberg.org/forgejo/forgejo.git (push) -``` - -### 1.3 分支策略 - -``` -main ← 稳定分支,基于上游 tag -├── develop ← 日常开发 -│ ├── feat/bounty ← 线 A -│ ├── feat/hackathon ← 线 B -│ └── feat/grants-credits ← 线 C -└── upstream/forgejo ← 跟踪上游(只读) -``` - -### 1.4 同步上游 - -```bash -git fetch upstream --tags -git checkout -b sync/v12.1.0 v12.1.0 -git cherry-pick # 不 rebase,cherry-pick 我们的 commit -git checkout main && git merge sync/v12.1.0 -git push origin main -``` - ---- - -## 二、开发环境:本地直装 - -Claude Code 在终端中运行,不需要 VS Code 或 DevContainer。直接在宿主机装依赖。 - -### 2.1 依赖安装 - -```bash -# Go >= 1.24 -# Node.js >= 20 -# Git, Make, SQLite3 -# gh CLI(GitHub CLI) - -# macOS -brew install go node git sqlite3 make gh - -# Ubuntu/Debian -sudo apt install golang-go nodejs npm git sqlite3 make -# gh: https://github.com/cli/cli/blob/trunk/docs/install_linux.md - -# 确保 ~/go/bin 在 PATH 中 -echo 'export PATH="$HOME/go/bin:$PATH"' >> ~/.bashrc -source ~/.bashrc - -# 验证 -go version # >= 1.24 -node --version # >= 20 -gh --version -``` - -### 2.2 gh CLI 配置 - -```bash -# 登录 GitHub -gh auth login - -# 常用命令 -gh repo list HackForger # Org 仓库 -gh issue list -R HackForger/hackforger # Issue 列表 -gh issue create -R HackForger/hackforger --title "Bug" --body "..." -gh pr list -R HackForger/hackforger # PR 列表 -gh pr create --title "feat: bounty" --body "..." # 在当前 repo 目录下 -gh pr merge 5 # merge PR -gh release create v0.1.0 --title "MVP" -``` - -### 2.3 首次构建 - -```bash -cd hackforger - -# 安装依赖 -make deps - -# 编译 -make build # 完整(backend + frontend) -make backend # 只编译后端(~10 秒) -make frontend # 只编译前端(~30 秒) - -# 启动 -./gitea web -# 访问 http://localhost:3000 → 安装向导 → 选 SQLite → 创建 admin -``` - ---- - -## 三、Claude Code 配置 - -### 3.1 CLAUDE.md(项目级指令) - -在项目根目录创建 `CLAUDE.md`: - -```markdown -# HackForger 开发指南 - -## 项目概述 -HackForger 是 Forgejo 的 Fork,新增了 Hackathon、Bounty、Grant、Credits 模块。 -所有新代码集中在 `*/hackforger/` 独立目录,对 Forgejo 原文件改动最少。 - -## 架构规则 -- Forgejo 是严格分层架构:routers → services → models → modules -- 上层只能调用下层,不能反向依赖 -- 新功能的 Go 包路径统一在 `forgejo.org/models/hackforger/`、`forgejo.org/services/hackforger/` 等 -- 数据库用 XORM ORM,定义 Go struct + tag 即可自动建表 -- 前端是 Go template 服务端渲染 + 局部 Vue 3 组件增强,不是 SPA - -## 目录结构 -- `models/hackforger/` — 数据模型(15 张表) -- `services/hackforger/` — 业务逻辑 -- `routers/api/v1/hackforger/` — REST API -- `routers/web/hackforger/` — Web 页面路由 -- `templates/hackforger/` — Go HTML 模板 -- `modules/hackforger/feed/` — Feed 事件类型定义 -- `web_src/js/features/hackforger/` — Vue 组件 - -## 命名规范 -- Go 文件: snake_case(hackathon.go, bounty_reward.go) -- Go 结构体: CamelCase(HackathonSubmission, BountyReward) -- API 路径: kebab-case(/api/v1/hackforger/grant-rounds) -- 模板文件: snake_case(judge_panel.tmpl) -- Vue 组件: PascalCase(BountyPanel.vue) - -## 常用命令 -- `make backend` — 编译后端 -- `make frontend` — 编译前端 -- `go test ./models/hackforger/... -v` — 跑 model 测试 -- `go test ./services/hackforger/... -v` — 跑 service 测试 -- `./gitea web` — 启动服务器(http://localhost:3000) - -## 重要约束 -- 使用 `gh` CLI 操作 GitHub(不要用 `tea`,那是 Codeberg/Forgejo 的 CLI) -- 不要修改 Forgejo 原有文件,除非在 implementation-plan-draft.md 第 1.2 节列出的 11 个注入点 -- 所有状态变更必须调用 PublishHackforgerAction 写 Feed 事件 -- Credits 的 Deposit/Redeem 必须在 db.WithTx 事务中 -- 内网 HackForger 实例地址: https://hackforger.inside.h2os.cloud -- API 基础路径: https://hackforger.inside.h2os.cloud/api/v1/hackforger/ -``` - -### 3.2 Subagent:代码地图扫描 - -```markdown - ---- -name: codebase-navigator -description: Scans the HackForger/Forgejo codebase and produces a focused code map for the current task. Use this before starting any feature implementation to understand relevant modules and avoid context overload. -tools: Read, Grep, Glob -model: sonnet ---- - -# Codebase Navigator - -You are a code architecture analyst for HackForger (a Forgejo fork). Your job is to scan the codebase and produce a **focused code map** — only the files and functions relevant to the current task. - -## Process - -1. Understand the task description -2. Identify which HackForger modules are involved (hackathon/bounty/grants/credits/feed/reputation) -3. Scan the relevant directories: - - `models/hackforger/` for data models - - `services/hackforger/` for business logic - - `routers/api/v1/hackforger/` for API endpoints - - `routers/web/hackforger/` for web routes - - `templates/hackforger/` for UI templates - - `web_src/js/features/hackforger/` for Vue components -4. If the task touches Forgejo native features, also scan: - - `models/repo/`, `models/issues/`, `models/org/` — for understanding existing models - - `services/repository/`, `services/issue/` — for reuse patterns - - `routers/web/repo/`, `routers/api/v1/repo/` — for routing patterns -5. Produce a code map in this format: - -\``` -## Code Map: [Task Name] - -### Directly Relevant Files -- `models/hackforger/bounty.go` — Bounty struct, BountyStatus enum - - CreateBounty(), GetBountyByIssueID(), UpdateBounty() -- `services/hackforger/bounty.go` — Business logic - - OnPullRequestMerged() — PR merge hook, triggers status change - - OnBountyCompleted() — Credits distribution - -### Forgejo Files to Reference (read-only) -- `models/issues/issue.go` — Issue struct (Bounty 1:1 binds to Issue) -- `services/pull/merge.go` — PR merge flow, need to add hook here - -### Files to Create/Modify -- `services/hackforger/bounty.go` — Add new function: ... -- `templates/hackforger/bounty/issue_panel.tmpl` — Create new template - -### Not Relevant (skip these) -- `models/hackforger/hackathon.go` — Not needed for this task -- `services/hackforger/grants.go` — Not needed -\``` - -## Rules -- NEVER include the entire Forgejo codebase. Only map what's needed. -- For large files, list only the relevant functions, not the entire file. -- Always check if a Forgejo native function already does what we need before suggesting new code. -- Flag any Forgejo files in the "11 injection points" list that this task might need to modify. -``` - -### 3.3 Subagent:Go/Forgejo 开发专家 - -```markdown - ---- -name: forgejo-dev -description: Expert in Forgejo's Go codebase patterns. Use for implementing HackForger features following Forgejo's architectural conventions. -tools: Read, Write, Edit, Bash, Grep, Glob -model: opus ---- - -# Forgejo Development Expert - -You are an expert Go developer specializing in Forgejo's architecture. When implementing HackForger features: - -## Architecture Rules -1. **Layer discipline**: routers → services → models → modules. Never import upward. -2. **XORM patterns**: Use `xorm:"pk autoincr"` tags. Register tables in `models/hackforger/init.go`. -3. **Error handling**: Return typed errors (e.g., `ErrBountyNotFound`), not generic errors. -4. **Context propagation**: Always pass `context.Context` as first parameter. -5. **Database transactions**: Use `db.WithTx(ctx, func(ctx context.Context) error { ... })` for multi-table operations. -6. **Feed events**: Every state change must call `PublishHackforgerAction()`. -7. **API style**: Follow Forgejo's existing patterns in `routers/api/v1/repo/`. JSON tags on all struct fields. Swagger comments on all handlers. -8. **Template style**: Use Go template syntax `{{.Field}}`, `{{range .Items}}`, `{{if .Condition}}`. CSS uses Tailwind classes. - -## Common Patterns Reference -- Creating a new model: See `models/issues/issue.go` for struct + CRUD pattern -- Creating a new API endpoint: See `routers/api/v1/repo/issue.go` for handler pattern -- Creating a new web route: See `routers/web/repo/issue.go` for page handler pattern -- Service with notifications: See `services/issue/issue.go` for notification pattern -- Cron task: See `services/cron/tasks.go` for registration pattern - -## Don'ts -- Don't use `tea` CLI. Use `gh` for GitHub operations. -- Don't create Forgejo Actions (`.forgejo/workflows/`) for the GitHub repo. Use GitHub Actions (`.github/workflows/`). -- Don't use React or any framework other than Vue 3 for frontend components. -- Don't add external Go dependencies without justification. Forgejo is conservative on deps. -``` - -### 3.4 Hooks(防止幻觉和常见错误) - -```json -// .claude/settings.json -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "intercept", - "pattern": "\\btea\\s+(issue|pr|repo|release|login)", - "message": "⚠️ 请使用 `gh` 而不是 `tea`。tea 是 Forgejo/Codeberg CLI,我们在 GitHub 上开发。等效命令:gh issue / gh pr / gh repo。API 调用请使用 curl + GITHUB_TOKEN 或 FORGEJO_TOKEN。" - }, - { - "type": "intercept", - "pattern": "codeberg\\.org/(Synnovator|HackForge)", - "message": "⚠️ 项目托管在 GitHub,不是 Codeberg。正确地址:github.com/HackForger/hackforger" - }, - { - "type": "intercept", - "pattern": "hackforge\\.inside\\.h2os\\.cloud", - "message": "⚠️ 内网实例地址已变更为 hackforger.inside.h2os.cloud(多了一个 r)。请更新 URL。" - } - ] - }, - { - "matcher": "Write|Edit", - "hooks": [ - { - "type": "intercept", - "pattern": "models/(repo|issues|org|user|actions|auth)/", - "message": "⚠️ 你正在修改 Forgejo 原有的 model 文件。HackForger 的新代码应该放在 models/hackforger/ 目录下。如果确实需要修改原有文件,请确认是 implementation-plan 中列出的 11 个注入点之一。" - } - ] - } - ] - } -} -``` - -### 3.5 自定义命令 - -```markdown - ---- -description: Build HackForger (backend, frontend, or both) -allowed-tools: Bash ---- -Build the project. Arguments: "backend", "frontend", "all" (default: all) - -If "$ARGUMENTS" is "backend": run `make backend` -If "$ARGUMENTS" is "frontend": run `make frontend` -Otherwise: run `make build` - -Report any compilation errors clearly. -``` - -```markdown - ---- -description: Run HackForger tests -allowed-tools: Bash, Read, Grep ---- -Run tests. Arguments: module name or "all" - -If "$ARGUMENTS" is "all": - Run `go test ./models/hackforger/... ./services/hackforger/... -v -count=1` - -If "$ARGUMENTS" is a module name (bounty, hackathon, grants, credits, reputation, feed): - Run `go test ./models/hackforger/... ./services/hackforger/... -v -run "Test.*${ARGUMENTS}" -count=1` - -If "$ARGUMENTS" is "e2e": - Run `go test ./tests/integration/... -v -tags='sqlite sqlite_unlock_notify' -run TestE2E` - -Report results with pass/fail summary. -``` - -```markdown - ---- -description: Generate a focused code map for a task -allowed-tools: Read, Grep, Glob ---- -Use the codebase-navigator agent to scan the codebase and produce a focused code map for: $ARGUMENTS - -Output the map showing only relevant files, functions, and Forgejo injection points needed. -``` - -```markdown - ---- -description: Test a HackForger API endpoint against the internal instance -allowed-tools: Bash ---- -Test an API endpoint on the internal HackForger instance. - -Usage: /api-test METHOD /path [JSON body] -Example: /api-test GET /hackforger/hackathons -Example: /api-test POST /hackforger/hackathons '{"name":"test"}' - -Execute: -\```bash -curl -s -X $METHOD \ - -H "Authorization: token $FORGEJO_TOKEN" \ - -H "Content-Type: application/json" \ - ${BODY:+-d "$BODY"} \ - "https://hackforger.inside.h2os.cloud/api/v1$PATH" | jq . -\``` - -If FORGEJO_TOKEN is not set, warn the user to set it first. -``` - -### 3.6 MCP 服务器配置 - -```json -// .mcp.json(项目级) -{ - "mcpServers": { - "forgejo": { - "command": "forgejo-mcp", - "args": [ - "--transport", "stdio", - "--url", "https://hackforger.inside.h2os.cloud" - ], - "env": { - "FORGEJO_ACCESS_TOKEN": "${FORGEJO_TOKEN}" - } - }, - "context7": { - "command": "npx", - "args": ["-y", "@anthropic/context7-mcp"] - } - } -} -``` - -安装 Forgejo MCP Server: +## Build ```bash -go install codeberg.org/goern/forgejo-mcp/v2@latest +TAGS="bindata sqlite sqlite_unlock_notify" make backend +make frontend ``` -### 3.7 环境变量 +Copy `custom/conf/app.example.ini` to an ignored local configuration path and adjust it for your development database. Do not commit real domains, accounts, credentials, or machine paths. -```bash -# ~/.bashrc 或 ~/.zshrc 追加 - -# GitHub -export GITHUB_TOKEN="your-github-token" - -# HackForger 内网实例 -export FORGEJO_URL="https://hackforger.inside.h2os.cloud" -export FORGEJO_TOKEN="your-hackforger-instance-token" - -# Claude Code 开发 -export HACKFORGER_DEV=true -``` - ---- - -## 四、GitHub Actions / Forgejo Actions Runner - -### 4.1 CI 策略 - -HackForger 使用双 CI 体系: -- **GitHub Actions** (`.github/workflows/`) — 主仓库 CI,跑测试、lint、构建 -- **Forgejo Actions** (`.forgejo/workflows/`) — 自部署的 HackForger 实例使用 - -开发阶段的核心逻辑在 Service 层,不依赖 CI。开发和单元测试在本地完成。 - -### 4.2 自部署实例:Forgejo Actions Runner - -在内网服务器上部署 Runner(用于自部署的 HackForger 实例): - -```yaml -# docker-compose.runner.yml(部署在内网服务器上) -services: - runner-dind: - image: docker:dind - container_name: hackforger-dind - privileged: true - command: ['dockerd', '-H', 'tcp://0.0.0.0:2375', '--tls=false'] - restart: unless-stopped - - runner: - image: data.forgejo.org/forgejo/runner:6.2 - container_name: hackforger-runner - depends_on: [runner-dind] - environment: - DOCKER_HOST: tcp://hackforger-dind:2375 - volumes: - - ./runner-data:/data - restart: unless-stopped -``` +Start the application with a local custom path: ```bash -# 注册 Runner -docker exec -it hackforger-runner forgejo-runner register \ - --instance https://hackforger.inside.h2os.cloud \ - --token RUNNER_TOKEN \ - --name hackforger-runner \ - --labels docker:docker://node:20-bookworm - -docker exec -it hackforger-runner forgejo-runner daemon +./gitea web --custom-path /tmp/hackforger-dev-custom ``` -### 4.3 上游仓库的 Actions/Bot - -Forgejo 上游仓库的 `.forgejo/workflows/` 是给 Forgejo 自身 CI 用的。我们的策略: - -| 上游配置 | 处理 | -|---------|------| -| `.forgejo/workflows/testing.yml` | **保留**,供自部署 HackForger 实例使用 | -| `.forgejo/workflows/release.yml` | **保留但暂不用**,我们暂时不需要自动 release | -| `.github/workflows/` | **新增**,GitHub 主仓库的 CI/CD | -| Renovate/Dependabot 配置 | **关闭**,依赖更新跟随上游 | -| Issue/PR 模板 | **替换为我们自己的模板** | - ---- - -## 五、内网 HackForger 实例 - -### 5.1 app.ini 配置 +Use `http://localhost:3000` unless your local configuration selects another port. -```ini -; 部署在内网服务器上 - -[server] -DOMAIN = hackforger.inside.h2os.cloud -ROOT_URL = https://hackforger.inside.h2os.cloud/ -HTTP_ADDR = 0.0.0.0 -HTTP_PORT = 3000 -SSH_DOMAIN = hackforger.inside.h2os.cloud -SSH_PORT = 22 -DISABLE_SSH = false -LFS_START_SERVER = true - -[database] -DB_TYPE = sqlite3 -PATH = /data/hackforger/hackforger.db - -[actions] -ENABLED = true -DEFAULT_ACTIONS_URL = https://data.forgejo.org - -[hackforger] -ENABLED = true - -[hackforger.hackathon] -MAX_TRACKS_PER_HACKATHON = 10 -AUTO_CREATE_TEAM_ON_APPROVE = true - -[hackforger.bounty] -ALLOWED_CURRENCIES = USD,CNY,EUR,credits -AUTO_EXPIRE_CHECK_INTERVAL = 5m - -[hackforger.credits] -ENABLED = true -INITIAL_BALANCE = 0 - -[hackforger.reputation] -RECALC_INTERVAL = 1h -SCORE_WEIGHTS = stars:1,bounties:5,hackathon_wins:10,grants:3 - -[hackforger.feed] -GLOBAL_EVENTS_IN_FEED = true - -[hackforger.assistant] -ENABLED = false -; 后续启用时配置: -; LLM_PROVIDER = ollama -; LLM_API_URL = http://localhost:11434/v1 -; LLM_MODEL = llama3.2 -``` - -### 5.2 HTTPS 配置 - -内网通过反向代理提供 HTTPS(假设已有 Caddy/Nginx): - -``` -# Caddyfile -hackforger.inside.h2os.cloud { - reverse_proxy localhost:3000 - tls internal # 或使用内网 CA 签发的证书 -} -``` - ---- - -## 六、开发工作流 - -### 6.1 日常流程(Claude Code) +## Test ```bash -# 1. 进入项目目录 -cd hackforger - -# 2. 启动 Claude Code -claude - -# 3. 开始任务前先扫描代码地图 -> /codemap bounty status machine implementation - -# 4. 开发功能 -> 实现 Bounty 的 exclusive 模式状态机,包括 Open → Claimed → InReview → Completed → Paid 的转换逻辑 - -# 5. 编译检查 -> /build backend - -# 6. 运行测试 -> /test bounty - -# 7. 测试 API -> /api-test GET /hackforger/bounties - -# 8. 提交 -> 提交当前改动,commit message: "feat(bounty): implement exclusive mode status machine" -``` - -### 6.2 团队协作 - -```bash -# 创建功能分支 -git checkout -b feat/bounty develop -git push -u origin feat/bounty - -# 开发完成后创建 PR(使用 gh) -gh pr create \ - --title "feat(bounty): implement exclusive mode" \ - --body "Implements the Bounty exclusive mode status machine..." \ - --base develop - -# Review + Merge -gh pr merge -``` - -### 6.3 测试 - -```bash -# 单元测试 -go test ./models/hackforger/... ./services/hackforger/... -v -count=1 - -# 只跑某个测试 -go test ./services/hackforger/... -v -run TestExclusiveBountyFlow_Happy - -# 集成测试(需要内网实例运行) -go test ./tests/integration/... -v -tags='sqlite sqlite_unlock_notify' -run TestE2E_Phase1 - -# 覆盖率 -go test ./models/hackforger/... ./services/hackforger/... -coverprofile=coverage.out -go tool cover -html=coverage.out +go test ./models/hackforger/... +go test ./services/hackforger/... +bash scripts/check-public-repository-boundary.sh ``` ---- +Use neutral fixtures created by the test itself. Store runtime screenshots and reports as CI artifacts, not tracked files. -## 七、首次启动检查清单 +## Private instance configuration -``` -□ GitHub 账号已加入 HackForger org,有 repo write 权限 -□ gh auth login 配置完成 -□ git clone 成功,remote 配置正确(origin=GitHub, upstream=Forgejo/Codeberg) -□ Go >= 1.24 / Node >= 20 / SQLite3 已安装 -□ make deps 成功 -□ make build 编译成功 -□ ./gitea web 启动成功 -□ 安装向导完成(SQLite, admin 账号) -□ CLAUDE.md 已放置在项目根目录 -□ .claude/agents/ 目录包含 codebase-navigator.md 和 forgejo-dev.md -□ .claude/settings.json 包含 hooks 配置(拦截 tea/codeberg 调用) -□ .claude/commands/ 包含 build/test/codemap/api-test 命令 -□ 环境变量 GITHUB_TOKEN / FORGEJO_TOKEN 已设置 -□ GET https://hackforger.inside.h2os.cloud/api/v1/hackforger/hackathons → 200 -``` +Branded content, deployment targets, production runbooks, and environment-specific smoke tests belong to the corresponding private business repository. Public deployment tooling accepts explicit configuration and must fail closed when required configuration is absent. diff --git a/docs/landing-page/issue-1-kpi-stats.md b/docs/landing-page/issue-1-kpi-stats.md deleted file mode 100644 index edc7bcd213..0000000000 --- a/docs/landing-page/issue-1-kpi-stats.md +++ /dev/null @@ -1,53 +0,0 @@ -## 背景 - -新落地页 (`custom/public/assets/landing/index.html`, spec 见 `docs/superpowers/specs/2026-04-29-hackforger-landing-page-design.md`) 的 "GLOBAL LEAGUE PULSE" 区块有 3 张 KPI 卡片: - -| 卡片 | 字段含义 | 当前显示 | HTML 定位锚点 | -|---|---|---|---| -| 算力消耗 | 大赛累计消耗算力虾粮 | "即将发送" 占位 | `pulse-compute-card` | -| 总计发放 | 已发放龙虾数 | "即将发送" 占位 | `pulse-issued-card` | -| 正在进行 | 活跃在线龙虾数 | "即将发送" 占位 | `pulse-live-card` (id `pulse-live-count`) | - -落地页设计者原始注释(HTML 文件内):「数字暂时显示'即将发送',停用随机更新」。本期实现保留占位文案,未对接实数据。 - -## 需要解决 - -### 1. 数据语义定义(必须先于实现) - -每个 KPI 的精确定义需要明确: - -**算力消耗** -- SUM 哪张表?credit redeem 全表 vs 仅本届联赛? -- 范围:所有 hackathon 累计 vs 仅"本届联赛"相关? -- 时间窗:永久累计 vs 单赛季? - -**总计发放** -- SUM credit issue 交易? -- 包含主办方手动发放 + 自动比赛奖励? -- 范围同上 - -**正在进行** -- COUNT 什么?hackathon participant?status=hacking 的? -- 是否含已注册但未提交的? -- 是否需要"在线"判断(如最近 N 分钟有活动)? - -### 2. 实现要点 - -- 新增 public API: `GET /api/v1/hackforger/landing/stats`,无需鉴权 -- 服务端缓存 5 分钟(聚合查询,避免每次访问打 DB) -- 落地页 JS fetch 后替换 `即将发送` 文案;失败时保留占位文案(fallback) - -## 验收 - -- [ ] 数据语义文档化(本 issue 评论或独立 spec) -- [ ] 测试员根据语义建测试 fixture 模板 -- [ ] 3 个 KPI 在任意符合条件的交易后 5 分钟内更新 -- [ ] 公开访问,无需登录 -- [ ] 接口被速率限制保护 -- [ ] E2E 验证(agent-browser,screenshots) - -## 相关 - -- Spec: `docs/superpowers/specs/2026-04-29-hackforger-landing-page-design.md` -- Implementation PR: #108 -- 跟进 issue: #110 (Auto-sync stages from hackathon DB), #111 (Backlog: templatization) diff --git a/docs/landing-page/issue-2-stages-sync.md b/docs/landing-page/issue-2-stages-sync.md deleted file mode 100644 index 5cebde76a9..0000000000 --- a/docs/landing-page/issue-2-stages-sync.md +++ /dev/null @@ -1,73 +0,0 @@ -## 背景 - -落地页 (`custom/public/assets/landing/index.html`) "赛项&命题预览" 区块 (section `#schedule`) 包含 12 张 Wave 卡片: - -- S1 数智OPC加速赛 × 4 Waves -- S2 跨境OPC加速赛 × 4 Waves -- S3 全球青年培育赛 × 4 Waves - -**本期已做的连接 (L0)**: -- ✅ 每张 Wave "立即报名" 按钮通过 `HACKFORGER_LANDING_CONFIG.stages` 映射到 `/hackathon/{slug}` -- ✅ slug 为 `tbd` 或 hackathon 不存在时弹"活动还没创建" -- ✅ active/disabled 状态由 config 驱动(event delegation) - -**本期未做**: -- ❌ Wave 名称("初赛/Wave 1" 等)硬编码 HTML -- ❌ 日期徽章("#报名 4.29-5.5" 等)硬编码 -- ❌ 赛区/赛道文案("数字文化赛道" 等)硬编码 -- ❌ active/disabled 状态需要手工编辑 config - -## 痛点 - -- Hackathon 阶段切换(registration_open → hacking)时,落地页按钮仍显示"立即报名",用户点了才发现报名已截止 -- 修改任何日期/状态需要 commit HTML,部署链路重,运营无法自助 - -## 提议 - -1. 新增 API: `GET /api/v1/hackforger/landing/stages?league=`,返回数组: - ```json - [ - { - "stage_id": "s1-w1", - "hackathon_slug": "league-2-s1-w1", - "name": "初赛/Wave 1", - "registration_window": { "start": "2026-04-29", "end": "2026-05-05" }, - "current_phase": "registration_open", - "tracks": ["数字文化", "数字营销", ...] - }, - ... - ] - ``` -2. 落地页加载时 JS 调此 API,hydrate 12 张卡片(替换文本 + 状态徽章 + active/disabled) -3. 失败时退回 HTML 内的硬编码内容(resilience;不破坏页面渲染) -4. 状态徽章自动随 hackathon phase 变化(#报名 → #开发 → #互评 → #公布晋级) - -## 待回答的产品问题 - -- HackForger 当前数据模型有 "League" 概念吗?还是每个 hackathon 独立? -- 如果没有 League,怎么把 "S1 Wave 2" 关联到某届联赛? - - 选项 A:命名约定(slug 必须是 `league-{N}-s{X}-w{Y}` 格式) - - 选项 B:新加 `league_id` 外键到 hackathon 表 - - 选项 C:新加独立 `landing_binding` 表(参考用户提的"运营人员系统内绑定落地页"想法) -- 一届 league 是否固定 12 个 Wave?还是可变? - -## 关联:运营人员"绑定落地页"功能 - -用户在 brainstorming 阶段提到希望"创建 hackathon 后,点击'绑定落地页',slug 批量填入 placeholder"。 -这是同一问题的产品视角——本 issue 的 schema 决策直接影响该 UI 的实现路径。 -建议两个一起 spec。 - -## 验收 - -- [ ] League 数据模型决策落定(issue 评论或独立 spec) -- [ ] 12 张卡片实时反映 hackathon DB 状态 -- [ ] 状态徽章自动切换 -- [ ] Fallback 不破坏渲染 -- [ ] E2E 验证 - -## 相关 - -- Spec: `docs/superpowers/specs/2026-04-29-hackforger-landing-page-design.md` -- Implementation PR: #108 -- 关联 issue: #109 (KPI stats), #111 (templatization backlog) -- 落地页 stages 区段位置: `custom/public/assets/landing/index.html` 中 `
` diff --git a/docs/landing-page/issue-3-templatization.md b/docs/landing-page/issue-3-templatization.md deleted file mode 100644 index 5c026e5244..0000000000 --- a/docs/landing-page/issue-3-templatization.md +++ /dev/null @@ -1,42 +0,0 @@ -## 背景 - -HackForger 落地页第一版 (PR TBD, spec 见 `docs/superpowers/specs/2026-04-29-hackforger-landing-page-design.md`) 作为单届赛事的定制页面手工编写,文件在 `custom/public/assets/landing/`。每办新一届赛事时,文案、资产、slug 等都需要改动。 - -讨论过 4 种模板化方案: - -| 等级 | 输入 | 输出 | 成本 | 灵活度 | -|---|---|---|---|---| -| L0 · 复制改文案 | 复制目录手动改 | `landing-event-N/` | ⏱ 30min/届 | ⭐⭐⭐⭐⭐(HTML 任意可改) | -| L1 · 字段替换模板 | YAML config + `template.html` | `landing/index.html` | 🛠 2-3 天初次 | ⭐⭐(结构必须固定) | -| L2 · 结构化模板 | DESIGN.md + schema.yaml + assets | 整个站 | 🛠 1-2 周 | ⭐⭐⭐⭐(section 可启用/变体) | -| L3 · CMS 化 | Web 后台编辑 | 实时渲染 | 🛠 4-6 周 | ⭐⭐⭐⭐⭐(运营自助) | - -**当前选择 L0**——直到累积至少 2 届真实数据后,再决定 L1+ schema。 - -## 触发抽象的条件(满足时再启动开发) - -- [ ] 已经办了 ≥ 2 届 league,能 diff 出真正的 variable / constant 字段 -- [ ] 频率 ≥ 4 届/年(否则 L0 完全够用) -- [ ] 编辑者从开发/设计师扩展到产品/运营("绑定落地页" UI 由 Issue #STAGES 跟进) - -## 反模式提醒 - -- ❌ **不要凭空设计模板 schema**——第一届做完你以为知道什么会变,第二届才发现真正会变的是没考虑的字段(比如 sponsors 突然加"特别支持"层级) -- ❌ **不要在没真实数据前讨论 CMS**——HackForger 当前没有专职运营团队迭代落地页内容 -- ✅ **YAGNI**:用过 2 次再抽象 - -## 决策建议 - -- **第 2 届来临时**:手工复制 + diff,记录 diff 内容到本 issue -- **累积 2 届 diff 数据后**:讨论 L1 schema,目标是把 ≥ 80% 的修改通过 YAML 完成 -- **L2/L3 暂不考虑**(先验证 L1 是否真有价值) - -## 状态 - -⏸ **Backlog** — 等待第 2 届赛事产生 diff 数据。 - -## 相关 - -- Spec: `docs/superpowers/specs/2026-04-29-hackforger-landing-page-design.md` -- Implementation PR: #108 -- 关联 issue: #109 (KPI 统计接入) + #110 (赛项&命题预览同步 + 运营绑定 UI) diff --git a/docs/notes/cross-origin-protection.md b/docs/notes/cross-origin-protection.md index 31ed3e2fab..2612b6045b 100644 --- a/docs/notes/cross-origin-protection.md +++ b/docs/notes/cross-origin-protection.md @@ -17,7 +17,7 @@ Forgejo (Go 1.26+) uses Go's built-in `net/http.CrossOriginProtection` for CSRF When Forgejo runs behind a reverse proxy: -- Browser sends `Origin: https://hackforger.inside.h2os.cloud` (the public URL) +- Browser sends `Origin: https://hackforger.example.invalid` (an example public URL) - The `Host` header may or may not match, depending on proxy configuration - If `Sec-Fetch-Site` is `same-origin`, everything works - If the browser omits `Sec-Fetch-Site` (older browser, non-standard client), Go falls back to `Origin` vs `Host` comparison @@ -44,13 +44,13 @@ var crossOriginProtection = func() *http.CrossOriginProtection { }() ``` -This ensures that requests with `Origin: https://hackforger.inside.h2os.cloud` are accepted even when the `Host` header differs (e.g., `localhost:3000` from the proxy). +This ensures that requests with `Origin: https://hackforger.example.invalid` are accepted even when the `Host` header differs (e.g., `localhost:3000` from the proxy). ### Why Not Hardcode? The origin is derived from `setting.AppURL` (which reads `[server] ROOT_URL` from `app.ini`). This means: - Different deployments automatically use the correct origin -- If accessed via IP (e.g., `http://192.168.1.100:3000`), only the configured `ROOT_URL` origin is trusted — other origins are rejected, which is the correct security behavior +- If accessed via a documentation IP (e.g., `http://192.0.2.10:3000`), only the configured `ROOT_URL` origin is trusted — other origins are rejected, which is the correct security behavior ## What NOT To Do @@ -63,7 +63,7 @@ The origin is derived from `setting.AppURL` (which reads `[server] ROOT_URL` fro ## Proxy-Specific Notes -### Caddy (our setup) +### Caddy example - Preserves `Host` header by default — no extra config needed - The `AddTrustedOrigin` fix handles edge cases diff --git a/docs/notes/gitflow.md b/docs/notes/gitflow.md index e024846db8..e6fb49811b 100644 --- a/docs/notes/gitflow.md +++ b/docs/notes/gitflow.md @@ -1,222 +1,21 @@ -# Gitflow + Smoke Test Gate +# HackForger contribution and promotion flow -How feature work moves from a developer's branch to `https://www.synnovator.com`, -and the rule that keeps unverified code from getting promoted. - -## Three-stage flow - -``` -[1] feat/* branch ─── developer / Claude does work - │ PR (Claude opens it) - ▼ -[2] v0.1-dev/hackforger (= "dev") ─── integration branch - │ │ - │ │ Claude restarts the Mac instance and - │ │ runs E2E smoke against - │ │ https://hackforger.inside.h2os.cloud - │ │ → commits docs/tests/e2e/reports/.md - │ fast-forward (admin) - ▼ -[3] prod ─── release branch - │ │ - │ │ Admin restarts the Mac instance against - │ │ the prod tip and manually walks the - │ │ acceptance steps at the same hostname - │ │ → admin signs off in the same report file - │ bash deploy/ecs/preflight.sh && build-linux.sh && scp + restart - ▼ -[4] www.synnovator.com (Huawei ECS 203.x) -``` - -All three test stages — Claude's E2E, admin's manual sign-off, and the eventual -"does it still work" sanity check — happen against the **same Mac instance** at -`https://hackforger.inside.h2os.cloud`. The branch you check out determines what -code is built; the data and OAuth setup don't change between dev/prod testing. - -## Branch ownership - -| Branch | Who advances it | What lands here | What testing happens | -| --- | --- | --- | --- | -| `feat/` | anyone (Claude or human) | day-to-day work, scoped to one change | unit + small integration tests | -| `v0.1-dev/hackforger` | PR merge | reviewed feature commits | **Claude E2E** at hackforger.inside.h2os.cloud | -| `prod` | admin (fast-forward only) | a subset of dev that admin has personally walked through | **admin manual sign-off** at hackforger.inside.h2os.cloud | -| (cloud HEAD) | `deploy/ecs/preflight.sh` + scp | whatever's on `prod` at the moment of deploy | **none after deploy** — verification happened on Mac | - -`prod` should never have commits that aren't already on `v0.1-dev/hackforger`. -The intent is "prod is dev minus the parts that haven't been admin-verified yet." - -## Smoke test report — the contract - -Every PR that touches user-facing code MUST produce one report file under -`docs/tests/e2e/reports/`. The file lives forever; it is the audit trail that -proves the change was exercised at hackforger.inside.h2os.cloud. - -### Naming convention - -``` -docs/tests/e2e/reports/YYYY-MM-DD-.md -``` - -Examples (existing): -- `2026-04-29-landing-page-impl.md` -- `2026-04-21-issue-83-milestones-epoch-date-report.md` - -### Required frontmatter - -The report MUST have a frontmatter block at the top so the preflight script -can find it: - -```markdown ---- -pr: 137 -commits: - - f20cffd9bc - - c1ffac9324 -tested_against: https://hackforger.inside.h2os.cloud -tested_at: 2026-05-02T11:00:00+0800 -e2e_owner: claude -admin_signoff: null # filled in step 3 ---- - -# — E2E smoke test - -(report body — what was tested, screenshots, bugs found, ...) -``` - -When the admin walks through the same change at stage 3, they edit the same -file and set `admin_signoff` to their handle + timestamp: - -```yaml -admin_signoff: - by: allen - at: 2026-05-02T15:00:00+0800 - notes: "tested login + register flows in zh-CN, all clean" -``` - -The cutoff for "user-facing" is generous: any change that affects what an -external user sees, clicks, types into, or reads. See the path list in -`deploy/ecs/preflight.sh` for the exact rule. - -## What user-facing means (preflight rule) - -Commits touching ONLY these paths can skip the report: - -``` -deploy/ scripts/ docs/ .github/ .forgejo/ -.gitignore .editorconfig Makefile CHANGELOG.md *.md (root) -``` - -Any commit touching paths outside that allowlist is "user-facing" and needs a -report. The `preflight.sh` script enforces this. - -## Promotion gate (`deploy/ecs/preflight.sh`) +HackForger uses reviewed feature branches, an integration branch, and a production branch: +```text +feature/* -> v0.1-dev/hackforger -> prod ``` -Inputs: - - the local `prod` branch HEAD - - the SHA last deployed to the cloud (read from - /var/lib/hackforger/.last-deploy on 203.x via SSH) -Behavior: - - For each commit between last-deploy SHA and prod HEAD: - - if commit touches only allowlisted paths → SKIP - - else search docs/tests/e2e/reports/ for a frontmatter `commits:` - list that contains this SHA AND a non-null `admin_signoff` - - if no matching report → FAIL with the SHA + message - - On success: echo "ready to deploy: " - - On failure: exit 1, print every offending commit -``` - -If preflight passes, the operator runs: - -```bash -bash deploy/ecs/build-linux.sh -scp gitea-linux-amd64 hackforger@203.119.115.130:/opt/hackforger/gitea-new -ssh hackforger@203.119.115.130 \ - 'sudo install -m 755 -o hackforger -g hackforger \ - /opt/hackforger/gitea-new /opt/hackforger/gitea && \ - sudo systemctl restart gitea && \ - echo $(git rev-parse HEAD) | sudo tee /var/lib/hackforger/.last-deploy' -``` - -The last line records the deployed SHA for the next preflight check. - -## Worked example — PR #137 ("登录/注册" copy) - -The OAuth button + entry-link copy change (PR #137, merged commit `f20cffd9bc`) -is a perfect case study because it has not yet been smoke-tested under this new -process. - -### Today's situation - -```bash -# What's on dev but not on prod (not yet tested by admin): -git log --oneline origin/v0.1-dev/hackforger ^prod 2>/dev/null -# (assuming prod branch exists and is at the post-clean-slate baseline) - -# What's on prod but not on cloud (not yet deployed): -git log --oneline prod ^ -``` - -`f20cffd9bc` would show up. Running `bash deploy/ecs/preflight.sh` would -report: - -``` -FAIL: f20cffd9bc feat(auth): rename OAuth button to "Continue with X" ... - no e2e report references this SHA - (touches: web_src/, templates/, options/locale/ — these need a report) -``` - -### Bringing it into compliance - -To get `f20cffd9bc` past the gate: - -1. **Restart Mac with the change**: - ```bash - git checkout v0.1-dev/hackforger - bash scripts/restart-gitea.sh - ``` -2. **Claude opens `https://hackforger.inside.h2os.cloud`** and walks the - sign-in flow: - - landing → "Sign in / Register" link visible at top-right (zh-CN + en-US) - - click → Logto consent → "Continue with X" button shows correct provider - - successful login → lands on `/dashboard` - - take screenshots at each step -3. **Claude writes the report**: - `docs/tests/e2e/reports/2026-05-02-pr-137-oauth-copy.md` - with frontmatter: - ```yaml - --- - pr: 137 - commits: [f20cffd9bc] - tested_against: https://hackforger.inside.h2os.cloud - tested_at: 2026-05-02T11:00:00+0800 - e2e_owner: claude - admin_signoff: null - --- - ``` -4. **Commit the report** (no PR needed, goes straight to dev). -5. **Admin pulls dev locally**, fast-forwards `prod`, restarts Mac - (`scripts/restart-gitea.sh`), opens the same URL, walks the same steps, - then edits the report's `admin_signoff` field and commits. -6. **Now preflight passes for `f20cffd9bc`** and it's eligible for the next - cloud deploy. -### Default — what to do if there's no report +## Public repository gates -If you discover a user-facing commit on `prod` without a report (we have one -right now: `f20cffd9bc` and `c1ffac9324`), the cloud deploy is **blocked** -until either: -- a backfill report is written and admin-signed, OR -- the commit is reverted off `prod` +1. Develop in an isolated worktree. +2. Run focused tests and `bash scripts/check-public-repository-boundary.sh`. +3. Open a pull request against `v0.1-dev/hackforger`. +4. Require the public repository boundary check and maintainer review. +5. Promote an already-reviewed commit to `prod`; do not rebuild an unrelated worktree HEAD. -There is no "deploy-now-test-later" override. The script has no `--force` -flag — if you need it, write the report first. +## Instance-specific release work -## What this document does not cover +Production targets, credentials, smoke reports, runtime screenshots, deployment markers, and rollback instructions belong to the private business repository for that instance. The public repository must not contain real environment facts. -- Hotfixes that bypass `dev` (e.g. a critical security patch directly on `prod`). - For now, treat these as a pause-the-process exception: write the report - retroactively within 24h. -- Schema migrations — they have their own rules in - `docs/notes/pg-migration-pitfalls.md`. -- Rollback. See `deploy/ecs/cutover-runbook.md` for the rollback drill. +Deployment tooling must consume explicit private configuration, back up affected runtime content, verify manifests, and record runtime evidence. Private content publishing is independent from application binary promotion. diff --git a/docs/notes/landing-date-driven-buttons.md b/docs/notes/landing-date-driven-buttons.md deleted file mode 100644 index 1f70479f19..0000000000 --- a/docs/notes/landing-date-driven-buttons.md +++ /dev/null @@ -1,131 +0,0 @@ -# Landing Page: Date-Driven Stage Buttons - -## Background - -The HackForger landing page (`custom/public/assets/landing/index.html`) showcases -multiple competition stages (S1/S2/S3/Finals). Each stage has Wave cards with -action buttons ("查看详情", "即刻报名", disabled "X日开始报名"). The button -state should reflect whether the Wave's time window is in the past, active, or -future. - -## Design Decision: Client-Side Date for UX Only - -**The button state is a UX convenience, NOT a security control.** - -### Why client-side? - -1. **Server already enforces deadlines independently.** The `Phase` model - (`models/hackforger/phase.go`) has `StartTime` / `EndTime` Unix timestamps. - `AllowsAction(ctx, "hackathon", id, "register")` calls `CurrentPhase()`, - which queries `start_time <= now AND end_time > now` using **server time** - (`time.Now().Unix()`). If no phase is active, the server rejects - registration on the `/hackathon/` detail page. - -2. **The landing page button only navigates.** `handleRegisterClick()` does - `window.location.href = '/hackathon/' + slug`. It does not submit anything. - The actual registration POST goes through `RegisterPost`, which calls - `AllowsAction` — server-validated. - -3. **Therefore**: Even if a user manipulates their system clock to make the - button appear "registerable", the server will reject the actual registration - if the phase has ended. The client-side date is purely cosmetic. - -### Risk Assessment - -| Scenario | Mitigated? | -|---|---| -| User changes clock → button shows "即刻报名" → clicks → server rejects | Yes, server validates phase | -| User changes clock → bypasses registration deadline | No — server independently enforces | -| Clock skew (few hours) causes wrong button state | Acceptable UX; server is source of truth | - -### When to use server time instead - -If a page element has **security implications** (e.g., displaying whether a -submission window is open before the user fills a form), the page must either: - -- Fetch server time via an API, OR -- Validate on the server when the form is submitted - -For landing page buttons, this is unnecessary. - -## Implementation Pattern - -### 1. Mark up each button with `data-wave-start` and `data-wave-end` - -```html - -``` - -Dates are `YYYY-MM-DD` (local time, start-of-day). - -### 2. Add JS to compute state on page load - -```javascript -(function() { - function parseDate(s) { - var parts = s.split('-'); - return new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10)); - } - function startOfDay(d) { return new Date(d.getFullYear(), d.getMonth(), d.getDate()); } - - var buttons = Array.from(document.querySelectorAll('[data-wave-start][data-wave-end]')); - var today = startOfDay(new Date()); - - buttons.forEach(function(btn) { - var start = parseDate(btn.getAttribute('data-wave-start')); - var end = parseDate(btn.getAttribute('data-wave-end')); - // End date is inclusive: window closes at end of that day - var endInclusive = new Date(end.getTime() + 24 * 60 * 60 * 1000 - 1); - - if (today < start) { - // Future: disable, show "X月X日开始报名" - setDisabled(btn, start); - } else if (today > endInclusive) { - // Past: disable, show "报名已结束" - setExpired(btn); - } - // else: within window — keep HTML default (active/enabled) - }); -})(); -``` - -### 3. Add i18n for dynamic button texts - -In the `DICT` object (the i18n dictionary): - -```javascript -"X月X日开始报名": "Opens MM-DD", // e.g. "7月24日开始报名": "Opens Jul 24" -"报名已结束": "Registration Closed", -``` - -### 4. Style states - -| State | bg | text | cursor | -|---|---|---|---| -| Active (default in HTML) | `bg-primary` | `text-black` | default | -| Future / Past | `bg-[#9CA3AF]` | `text-[#4B5563]` | `cursor-not-allowed` | - -Toggle via `classList.remove(...)` / `classList.add(...)`. - -## Reusing for Future Events - -When adding a new season (e.g., 2027 Lingang): - -1. Add `data-wave-start` / `data-wave-end` to each new Wave button. -2. Ensure the corresponding hackathon activity has phases configured in the - backend with correct `start_time` / `end_time`. **Without phases, the server - does not enforce any deadline** (`AllowsAction` returns `true` when `count == 0`). -3. The JS logic is generic — no code changes needed for new dates. -4. Add any new button text variants to `DICT` for i18n. - -## File Location - -- Implementation: `custom/public/assets/landing/index.html` (inline ` -``` - -- [ ] **Step 3: Add import in `web_src/js/index.js`** - -Add import at top of file: -```js -import {initHackforger} from './features/hackforger/init.js'; -``` - -Add call inside the `onDomReady()` block: -```js -initHackforger(); -``` - -- [ ] **Step 4: Verify frontend builds** - -Run: `cd /Users/h2oslabs/Workspace/hackforger && make frontend` -Expected: SUCCESS (or at least no import/syntax errors) - -- [ ] **Step 5: Commit** - -```bash -git add web_src/js/features/hackforger/ web_src/js/components/hackforger/ web_src/js/index.js -git commit -m "feat(hackforger): add frontend directory scaffolding with lazy-load init" -``` - -### Task 18: i18n Keys - -**Files:** -- Modify: `options/locale/locale_en-US.ini` - -- [ ] **Step 1: Add HackForger i18n section** - -Append to the end of `options/locale/locale_en-US.ini`: -```ini - -[hackforger] -explore.hackathons = Hackathons -explore.bounties = Bounties -explore.grants = Grants -explore.coming_soon = Coming soon... - -hackathon.new = New Hackathon -hackathon.status.draft = Draft -hackathon.status.registration = Registration -hackathon.status.hacking = Hacking -hackathon.status.judging = Judging -hackathon.status.finished = Finished - -bounty.status.open = Open -bounty.status.claimed = Claimed -bounty.status.in_review = In Review -bounty.status.completed = Completed -bounty.status.paid = Paid -bounty.status.expired = Expired -bounty.status.cancelled = Cancelled -bounty.mode.exclusive = Exclusive -bounty.mode.competitive = Competitive - -grants.round.status.setup = Setup -grants.round.status.open = Open -grants.round.status.reviewing = Reviewing -grants.round.status.finalized = Finalized -grants.round.status.distributed = Distributed - -credits.balance = Balance -credits.transactions = Transactions -credits.redeem = Redeem -credits.redeem.options = Redeem Options -credits.redeem.orders = Orders - -reputation.score = Reputation Score -reputation.leaderboard = Leaderboard - -feed.following = Following -feed.global = Global -``` - -- [ ] **Step 2: Add cron task i18n keys** - -These must exist in the `[admin]` section for cron task registration. Find the `[admin]` section and add: -```ini -dashboard.hackforger_hackathon_status = Check hackathon phase transitions -dashboard.hackforger_bounty_expiry = Expire stale bounties -dashboard.hackforger_reputation_recalc = Recalculate reputation scores -dashboard.hackforger_grant_deadline = Check grant round deadlines -``` - -- [ ] **Step 3: Verify the locale file is valid** - -Run: `cd /Users/h2oslabs/Workspace/hackforger && go build ./...` -Expected: SUCCESS - -- [ ] **Step 4: Commit** - -```bash -git add options/locale/locale_en-US.ini -git commit -m "feat(hackforger): add i18n keys for all hackforger modules" -``` - -### Task 19: Cron Task Registration - -**Files:** -- Create: `services/cron/tasks_hackforger.go` -- Modify: `services/cron/cron.go` (injection point ③) - -- [ ] **Step 1: Create `services/cron/tasks_hackforger.go`** - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package cron - -import ( - "context" - - user_model "forgejo.org/models/user" -) - -func registerHackforgerHackathonStatus() { - RegisterTaskFatal("hackforger_hackathon_status", &BaseConfig{ - Enabled: true, - RunAtStart: false, - Schedule: "@every 5m", - }, func(ctx context.Context, _ *user_model.User, _ Config) error { - // Phase 1: call hackforger_service.CheckHackathonTransitions(ctx) - return nil - }) -} - -func registerHackforgerBountyExpiry() { - RegisterTaskFatal("hackforger_bounty_expiry", &BaseConfig{ - Enabled: true, - RunAtStart: false, - Schedule: "@every 5m", - }, func(ctx context.Context, _ *user_model.User, _ Config) error { - // Phase 1: call hackforger_service.ExpireOldBounties(ctx) - return nil - }) -} - -func registerHackforgerReputationRecalc() { - RegisterTaskFatal("hackforger_reputation_recalc", &BaseConfig{ - Enabled: true, - RunAtStart: false, - Schedule: "@every 1h", - }, func(ctx context.Context, _ *user_model.User, _ Config) error { - // Phase 1: call hackforger_service.RecalculateAllReputations(ctx) - return nil - }) -} - -func registerHackforgerGrantDeadline() { - RegisterTaskFatal("hackforger_grant_deadline", &BaseConfig{ - Enabled: true, - RunAtStart: false, - Schedule: "@every 1h", - }, func(ctx context.Context, _ *user_model.User, _ Config) error { - // Phase 1: call hackforger_service.CheckGrantDeadlines(ctx) - return nil - }) -} - -func initHackforgerTasks() { - registerHackforgerHackathonStatus() - registerHackforgerBountyExpiry() - registerHackforgerReputationRecalc() - registerHackforgerGrantDeadline() -} -``` - -- [ ] **Step 2: Register in `services/cron/cron.go`** - -Add `initHackforgerTasks()` call after `initActionsTasks()` inside `NewContext()`: -```go -initHackforgerTasks() -``` - -- [ ] **Step 3: Verify compile** - -Run: `cd /Users/h2oslabs/Workspace/hackforger && go build ./services/cron/...` -Expected: SUCCESS - -- [ ] **Step 4: Commit** - -```bash -git add services/cron/tasks_hackforger.go services/cron/cron.go -git commit -m "feat(hackforger): register 4 cron tasks (hackathon/bounty/reputation/grant)" -``` - ---- - -## Chunk 5: Package Init, Test Infrastructure, Verification + Tests - -### Task 20: Package init.go + Test Import Registration - -**Files:** -- Create: `models/hackforger/init.go` -- Modify: `modules/testimport/import.go` - -- [ ] **Step 1: Create `models/hackforger/init.go`** - -This file serves as the package documentation entry point. Individual model files register themselves via their own `init()` functions. - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -// Package hackforger contains the data models for HackForger's -// Hackathon, Bounty, Grant, Credits, and Reputation modules. -// Each model file registers its struct via db.RegisterModel() in init(). -package hackforger -``` - -- [ ] **Step 2: Add blank import in `modules/testimport/import.go`** - -This is critical — without it, integration tests won't register HackForger tables and will fail. - -Add to the import block: -```go -_ "forgejo.org/models/hackforger" -``` - -- [ ] **Step 3: Verify compile** - -Run: `cd /Users/h2oslabs/Workspace/hackforger && go build ./...` -Expected: SUCCESS - -- [ ] **Step 4: Commit** - -```bash -git add models/hackforger/init.go modules/testimport/import.go -git commit -m "feat(hackforger): add package init.go and testimport registration" -``` - -### Task 21: Create Minimal Test Fixture YAML Files - -**Files:** -- Create: `models/fixtures/hackforger_hackathon.yml` (and 15 more) - -- [ ] **Step 1: Create empty fixture YAML files for all 16 tables** - -Each file is initially empty (just a YAML comment). These will be populated with test data in Phase 1. The files must exist for the test infrastructure to recognize the tables. - -```bash -cd /Users/h2oslabs/Workspace/hackforger -for table in hackathon hackathon_track hackathon_registration hackathon_submission hackathon_judge_score bounty bounty_reward bounty_application bounty_winner grant_round grant_project credit_account credit_transaction redeem_option redeem_order reputation; do - echo "# HackForger test fixture: $table" > "models/fixtures/$table.yml" -done -``` - -- [ ] **Step 2: Commit** - -```bash -git add models/fixtures/*.yml -git commit -m "feat(hackforger): add empty test fixture YAML files for all 16 tables" -``` - -### Task 22: Full Build Verification - -- [ ] **Step 1: Run full backend build** - -Run: `cd /Users/h2oslabs/Workspace/hackforger && make backend` -Expected: SUCCESS — binary compiles with all new code - -- [ ] **Step 2: Run full frontend build** - -Run: `cd /Users/h2oslabs/Workspace/hackforger && make frontend` -Expected: SUCCESS - -- [ ] **Step 3: Verify migration registers correctly** - -Run: `cd /Users/h2oslabs/Workspace/hackforger && go test ./models/forgejo_migrations/ -run TestMigrations -v` -Expected: If there's a migration registration test, it should pass. If not, at minimum `go build` confirms the init() was picked up. - -### Task 23: Model Layer Unit Tests - -**Files:** -- Create: `models/hackforger/hackathon_test.go` -- Create: `models/hackforger/bounty_test.go` -- Create: `models/hackforger/credits_test.go` - -Note: These tests verify struct registration and basic XORM operations. They require the Forgejo test infrastructure (`tests/integration/` framework or `models/unittest/`). Check how existing model tests are structured before writing. - -- [ ] **Step 1: Research existing model test patterns** - -Run: `ls models/issues/*_test.go | head -5` and read one to understand the test setup pattern (e.g., `TestMain`, `unittest.PrepareTestEnv`). - -- [ ] **Step 2: Create `models/hackforger/hackathon_test.go`** - -The exact test setup depends on the patterns found in Step 1. At minimum, test that the structs can be inserted and queried: - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package hackforger_test - -// Tests will follow the pattern discovered in Step 1. -// Key test cases from test-plan-draft.md: -// -// TestCreateHackathon — normal create, verify ID/Slug/Status/CreatedUnix -// TestCreateHackathon_DuplicateSlug — duplicate slug → ErrHackathonSlugExists -// TestGetHackathonByID — read correct -// TestGetHackathonBySlug — read correct -// TestListHackathons_FilterByStatus — filter by status -``` - -- [ ] **Step 3: Write and run tests based on discovered patterns** - -Run: `cd /Users/h2oslabs/Workspace/hackforger && go test ./models/hackforger/... -v` -Expected: All tests PASS - -- [ ] **Step 4: Commit** - -```bash -git add models/hackforger/*_test.go -git commit -m "test(hackforger): add model layer unit tests for Hackathon, Bounty, Credits" -``` - -### Task 24: Final Phase 0 Verification - -- [ ] **Step 1: Verify all 15 model structs registered** - -Run: `cd /Users/h2oslabs/Workspace/hackforger && grep -r "db.RegisterModel" models/hackforger/ | wc -l` -Expected: 15 (one per table: Hackathon, HackathonTrack, HackathonRegistration, HackathonSubmission, HackathonJudgeScore, Bounty, BountyReward, BountyApplication, BountyWinner, GrantRound, GrantProject, CreditAccount, CreditTransaction, RedeemOption, RedeemOrder, Reputation) - -Actually 16 — 15 tables + Reputation = 16 `RegisterModel` calls. The implementation plan says "15 tables" but Reputation is the 16th struct. Verify: Hackathon(1) + Track(2) + Registration(3) + Submission(4) + JudgeScore(5) + Bounty(6) + BountyReward(7) + BountyApplication(8) + BountyWinner(9) + GrantRound(10) + GrantProject(11) + CreditAccount(12) + CreditTransaction(13) + RedeemOption(14) + RedeemOrder(15) + Reputation(16) = **16 RegisterModel calls**. - -- [ ] **Step 2: Verify directory structure matches plan** - -Run: -```bash -find models/hackforger services/hackforger routers/api/v1/hackforger routers/web/hackforger templates/hackforger web_src/js/features/hackforger web_src/js/components/hackforger -type f | sort -``` -Expected: All planned files exist - -- [ ] **Step 3: Verify injection points** - -Check that all 4 P0 injection points are in place: -1. `routers/api/v1/api.go` — contains `/hackforger` group -2. `routers/web/web.go` — contains hackforger routes -3. `routers/init.go` — contains `hackforger_service.Init` -4. `services/cron/cron.go` — contains `initHackforgerTasks()` - -Run: -```bash -grep -n "hackforger" routers/api/v1/api.go routers/web/web.go routers/init.go services/cron/cron.go -``` -Expected: Each file shows the injection - -- [ ] **Step 4: Final commit if any fixups needed** - -```bash -git status -# If clean: Phase 0 complete -# If changes: git add ... && git commit -m "fix(hackforger): phase 0 fixups" -``` - ---- - ---- - -## Chunk 6: Manual E2E Verification - -### Task 25: Generate E2E Prompt and Report Template - -**Files:** -- Create: `docs/tests/e2e/p0-infrastructure-e2e-prompt.md` -- Create: `docs/tests/e2e/p0-infrastructure-e2e-report.md` - -- [ ] **Step 1: Create E2E prompt guide** - -See `docs/tests/e2e/p0-infrastructure-e2e-prompt.md` (already generated with the plan). - -- [ ] **Step 2: Create E2E report template** - -See `docs/tests/e2e/p0-infrastructure-e2e-report.md` (already generated with the plan). - -- [ ] **Step 3: Hand off to human for manual verification** - -Notify the user that P0 implementation is complete and ready for manual E2E testing. The user should follow the prompt guide, fill in the report, and flag any issues. - -- [ ] **Step 4: Commit** - -```bash -git add docs/tests/e2e/p0-infrastructure-e2e-prompt.md docs/tests/e2e/p0-infrastructure-e2e-report.md -git commit -m "docs(hackforger): add P0 manual E2E test prompt and report template" -``` - ---- - -## Summary - -| Chunk | Tasks | Files Created | Files Modified | Commits | -|-------|-------|--------------|----------------|---------| -| 1: Data Models | 1-6 | 12 model files | — | 6 | -| 2: Feed + Migration + Notifier | 7-9 | 3 files | `routers/init.go` | 3 | -| 3: CRUD (models) + Credits Service | 10-14 | 1 service file | 5 model files (append CRUD) | 5 | -| 4: Routers + Frontend + i18n | 15-19 | 10+ files | 4 injection points | 5 | -| 5: Init + Fixtures + Verification + Tests | 20-24 | init.go + 16 fixtures + tests | `modules/testimport/import.go` | 4-5 | -| 6: Manual E2E Verification | 25 | 2 doc files | — | 1 | -| **Total** | **25 tasks** | **~47 files** | **~7 files** | **~24 commits** | - -### Review fixes applied (from code-reviewer): -- **CRUD in models layer**: Data access functions (Get/List/Create/Update/Delete) moved from `services/` to `models/hackforger/`, matching Forgejo convention -- **Custom audience distribution**: `PublishHackforgerAction` uses direct action insertion, not `NotifyWatchers` (which only handles repo watchers) -- **Test infrastructure**: Added `modules/testimport/import.go` blank import (critical for integration tests) -- **Status filter zero-value**: Changed to pointer types (`*HackathonStatus`, `*BountyStatus`, `*GrantRoundStatus`) so nil = no filter -- **Error semantics**: `ErrInsufficientCredits` and `ErrOutOfStock` now wrap `util.ErrInvalidArgument` instead of `util.ErrPermissionDenied` -- **Explore routes**: Added inside existing `/explore` group (inherits `ignExploreSignIn` middleware) -- **Package init.go**: Added `models/hackforger/init.go` as package doc -- **Test fixtures**: Added task for empty YAML fixture files to unblock Phase 1 integration tests -- **Table count**: Clarified 16 tables (spec says 15 but JudgeScore is the 16th) diff --git a/docs/superpowers/plans/2026-03-28-phase1-bounty.md b/docs/superpowers/plans/2026-03-28-phase1-bounty.md deleted file mode 100644 index 4e52d162b8..0000000000 --- a/docs/superpowers/plans/2026-03-28-phase1-bounty.md +++ /dev/null @@ -1,2566 +0,0 @@ -# Phase 1 Bounty Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Implement the complete Bounty module — model corrections, 7-state state machine, full API, web UI, Vue panel, Feed integration, and E2E tests. - -**Architecture:** Bottom-up approach: Model → Service → Notifier → API → Web → Vue → Tests. Bounty line also owns the shared Notifier audience resolution (bitmask rewrite) used by all three parallel lines. - -**Tech Stack:** Go (XORM, go-chi router), Go templates (SSR), Vue 3 Options API, Fomantic UI, SQLite for tests. - -**Spec:** `docs/superpowers/specs/2026-03-28-phase1-bounty-design.md` - ---- - -## File Map - -### New Files - -| File | Responsibility | -|------|---------------| -| `models/forgejo_migrations/v14c_fix-bounty-status-mode.go` | Migration: remap BountyStatus 3→6, delete Mode=2 rows (runs after v14c_add-hackforger-tables due to alphabetical ordering) | -| `models/hackforger/bounty_test.go` | Model CRUD unit tests + TestMain | -| `models/hackforger/main_test.go` | TestMain for hackforger model package | -| `models/fixtures/bounty.yml` | Test fixture data | -| `models/fixtures/bounty_reward.yml` | Test fixture data | -| `models/fixtures/bounty_application.yml` | Test fixture data | -| `models/fixtures/bounty_winner.yml` | Test fixture data | -| `services/hackforger/bounty.go` | Bounty state machine + business logic | -| `services/hackforger/bounty_test.go` | Service unit tests | -| `services/hackforger/main_test.go` | TestMain for hackforger service package | -| `routers/web/hackforger/bounty.go` | Web handlers (explore, new bounty form) | -| `templates/hackforger/bounty/explore.tmpl` | Full Bounty explore page | -| `templates/hackforger/bounty/new.tmpl` | Create Bounty form | -| `templates/hackforger/bounty/panel.tmpl` | Issue sidebar Bounty panel (SSR) | -| `templates/hackforger/bounty/badge.tmpl` | Issue list Bounty badge | -| `web_src/js/components/hackforger/BountyPanel.vue` | Interactive Bounty panel (Vue) | -| `docs/tests/e2e/p1-bounty-e2e-prompt.md` | Manual E2E test prompt | -| `docs/tests/e2e/p1-bounty-e2e-report.md` | E2E report template | - -### Modified Files - -| File | Change | -|------|--------| -| `models/hackforger/bounty.go` | Expand BountyStatus (7 states), BountyMode (2 modes), add DeleteBounty | -| `models/hackforger/bounty_application.go` | Add CRUD functions | -| `models/hackforger/bounty_reward.go` | Add CRUD functions + error types | -| `models/hackforger/bounty_winner.go` | Add CRUD functions | -| `models/hackforger/action_types.go` | Add ActionBountyPaid (43) | -| `services/hackforger/notifier.go` | Bitmask AudienceType + full audience resolution + MergePullRequest hook | -| `routers/api/v1/hackforger/bounty.go` | Replace skeleton with full API handlers | -| `routers/api/v1/api.go` | Register Bounty repo-level + global routes | -| `routers/web/web.go` | Register Bounty web routes | -| `routers/web/hackforger/hackathon.go` | Remove ExploreBounties (moved to bounty.go) | -| `routers/web/repo/issue.go` | Load Bounty data in ViewIssue + batch load in list | -| `templates/repo/issue/view_content/sidebar.tmpl` | Inject Bounty panel | -| `templates/shared/issuelist.tmpl` | Inject Bounty badge | -| `options/locale/locale_en-US.ini` | Add Bounty i18n keys | -| `web_src/js/features/hackforger/init.js` | Already has BountyPanel mount (verify) | - ---- - -## Chunk 1: Model Layer - -### Task 1: Create Test Infrastructure + Fixtures - -**Files:** -- Create: `models/hackforger/main_test.go` -- Create: `models/fixtures/bounty.yml` -- Create: `models/fixtures/bounty_reward.yml` -- Create: `models/fixtures/bounty_application.yml` -- Create: `models/fixtures/bounty_winner.yml` - -- [ ] **Step 1: Create TestMain for hackforger models** - -```go -// models/hackforger/main_test.go -package hackforger - -import ( - "testing" - - "forgejo.org/models/unittest" -) - -func TestMain(m *testing.M) { - unittest.MainTest(m) -} -``` - -- [ ] **Step 2: Create bounty fixture with test data** - -```yaml -# models/fixtures/bounty.yml -- - id: 1 - repo_id: 1 - issue_id: 1 - publisher_id: 2 - claimer_id: 0 - title: "Fix authentication bug" - status: 0 # Open - mode: 0 # Exclusive - deadline: 1735689600 - created_unix: 1672578000 - updated_unix: 1672578000 - -- - id: 2 - repo_id: 1 - issue_id: 2 - publisher_id: 2 - claimer_id: 0 - title: "Best CLI tool challenge" - status: 0 # Open (Competitive has no Claimed state) - mode: 1 # Competitive - deadline: 1735689600 - created_unix: 1672578100 - updated_unix: 1672578100 -``` - -- [ ] **Step 3: Create bounty_reward fixture** - -```yaml -# models/fixtures/bounty_reward.yml -- - id: 1 - bounty_id: 1 - type: "money" - amount: 500 - currency: "USD" - credits: 0 - rank: 1 - note: "" - created_unix: 1672578000 - -- - id: 2 - bounty_id: 1 - type: "credits" - amount: 0 - currency: "" - credits: 500 - rank: 1 - note: "bonus credits" - created_unix: 1672578000 - -- - id: 3 - bounty_id: 2 - type: "credits" - amount: 0 - currency: "" - credits: 1000 - rank: 1 - note: "1st place" - created_unix: 1672578100 -``` - -- [ ] **Step 4: Create bounty_application fixture** - -```yaml -# models/fixtures/bounty_application.yml -- - id: 1 - bounty_id: 1 - user_id: 4 - status: 0 # Pending - message: "I can fix this" - created_unix: 1672578200 - updated_unix: 1672578200 -``` - -- [ ] **Step 5: Create bounty_winner fixture (empty)** - -```yaml -# models/fixtures/bounty_winner.yml -# empty — winners are created by service tests -``` - -- [ ] **Step 6: Verify fixtures load** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-bounty && go test ./models/hackforger/ -run TestMain -v -count=1 -tags "bindata sqlite sqlite_unlock_notify"` - -Expected: PASS (TestMain loads fixtures without error) - -- [ ] **Step 7: Commit** - -```bash -git add models/hackforger/main_test.go models/fixtures/bounty*.yml -git commit -m "test: add hackforger bounty test infrastructure and fixtures" -``` - ---- - -### Task 2: Expand BountyStatus and BountyMode - -**Files:** -- Modify: `models/hackforger/bounty.go` -- Create: `models/forgejo_migrations/v14c_fix-bounty-status-mode.go` - -- [ ] **Step 1: Update BountyStatus enum (4 → 7 states)** - -In `models/hackforger/bounty.go`, replace the status and mode constants: - -```go -// BountyStatus represents the status of a bounty. -type BountyStatus int - -const ( - BountyStatusOpen BountyStatus = 0 - BountyStatusClaimed BountyStatus = 1 // was InProgress - BountyStatusInReview BountyStatus = 2 - BountyStatusCompleted BountyStatus = 3 - BountyStatusPaid BountyStatus = 4 - BountyStatusExpired BountyStatus = 5 - BountyStatusCancelled BountyStatus = 6 // was 3, moved to 6 -) - -// BountyMode represents how a bounty is assigned. -type BountyMode int - -const ( - BountyModeExclusive BountyMode = 0 // was FirstCome - BountyModeCompetitive BountyMode = 1 // was Application -) -``` - -- [ ] **Step 2: Add DeleteBounty function** - -Append to `models/hackforger/bounty.go`: - -```go -// DeleteBounty deletes a bounty by its ID. -func DeleteBounty(ctx context.Context, id int64) error { - _, err := db.GetEngine(ctx).ID(id).Delete(new(Bounty)) - return err -} -``` - -- [ ] **Step 3: Create migration file** - -```go -// models/forgejo_migrations/v14c_fix-bounty-status-mode.go -package forgejo_migrations - -import ( - "xorm.io/xorm" -) - -func init() { - registerMigration(&Migration{ - Description: "fix bounty status and mode values for Phase 1", - Upgrade: fixBountyStatusMode, - }) -} - -func fixBountyStatusMode(x *xorm.Engine) error { - // Remap old Cancelled(3) → new Cancelled(6) - if _, err := x.Exec("UPDATE bounty SET status = 6 WHERE status = 3"); err != nil { - return err - } - // Remove any Invitation mode(2) rows - if _, err := x.Exec("DELETE FROM bounty WHERE mode = 2"); err != nil { - return err - } - return nil -} -``` - -- [ ] **Step 4: Verify compilation** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-bounty && go build ./models/hackforger/... && go build ./models/forgejo_migrations/...` - -Expected: no errors - -- [ ] **Step 5: Commit** - -```bash -git add models/hackforger/bounty.go models/forgejo_migrations/v14c_fix-bounty-status-mode.go -git commit -m "feat(bounty): expand BountyStatus to 7 states, BountyMode to 2 modes - -Add migration to remap old Cancelled(3)→6 and remove Invitation mode." -``` - ---- - -### Task 3: Complete BountyApplication CRUD - -**Files:** -- Modify: `models/hackforger/bounty_application.go` - -- [ ] **Step 1: Write tests for BountyApplication CRUD** - -In `models/hackforger/bounty_test.go`: - -```go -package hackforger - -import ( - "testing" - - "forgejo.org/models/db" - "forgejo.org/models/unittest" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestCreateBountyApplication(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - app := &BountyApplication{ - BountyID: 1, - UserID: 5, - Message: "I want to work on this", - } - require.NoError(t, CreateBountyApplication(db.DefaultContext, app)) - assert.Greater(t, app.ID, int64(0)) - assert.Equal(t, ApplicationStatusPending, app.Status) -} - -func TestCreateBountyApplication_Duplicate(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // User 4 already applied to bounty 1 (from fixture) - app := &BountyApplication{ - BountyID: 1, - UserID: 4, - Message: "duplicate", - } - err := CreateBountyApplication(db.DefaultContext, app) - assert.True(t, IsErrAlreadyApplied(err)) -} - -func TestListBountyApplications(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - apps, count, err := ListBountyApplications(db.DefaultContext, ListBountyApplicationsOptions{ - BountyID: 1, - }) - require.NoError(t, err) - assert.Equal(t, int64(1), count) - assert.Len(t, apps, 1) - assert.Equal(t, int64(4), apps[0].UserID) -} - -func TestUpdateBountyApplication(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - app := unittest.AssertExistsAndLoadBean(t, &BountyApplication{ID: 1}) - app.Status = ApplicationStatusAccepted - require.NoError(t, UpdateBountyApplication(db.DefaultContext, app)) - - updated := unittest.AssertExistsAndLoadBean(t, &BountyApplication{ID: 1}) - assert.Equal(t, ApplicationStatusAccepted, updated.Status) -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-bounty && go test ./models/hackforger/ -run "TestCreateBountyApplication|TestListBountyApplications|TestUpdateBountyApplication" -v -count=1 -tags "bindata sqlite sqlite_unlock_notify"` - -Expected: FAIL — functions not defined - -- [ ] **Step 3: Implement CRUD in bounty_application.go** - -Add to `models/hackforger/bounty_application.go`: - -```go -import ( - "context" - "fmt" - - "forgejo.org/models/db" - "forgejo.org/modules/timeutil" - "forgejo.org/modules/util" - - "xorm.io/builder" -) - -// CreateBountyApplication creates a new application. Returns ErrAlreadyApplied -// if the user has already applied to the same bounty. -func CreateBountyApplication(ctx context.Context, app *BountyApplication) error { - has, err := db.GetEngine(ctx). - Where("bounty_id = ? AND user_id = ?", app.BountyID, app.UserID). - Exist(new(BountyApplication)) - if err != nil { - return err - } - if has { - return ErrAlreadyApplied{BountyID: app.BountyID, UserID: app.UserID} - } - _, err = db.GetEngine(ctx).Insert(app) - return err -} - -// GetBountyApplicationByID returns an application by ID. -func GetBountyApplicationByID(ctx context.Context, id int64) (*BountyApplication, error) { - app := new(BountyApplication) - has, err := db.GetEngine(ctx).ID(id).Get(app) - if err != nil { - return nil, err - } - if !has { - return nil, fmt.Errorf("bounty application does not exist [id: %d]", id) - } - return app, nil -} - -// ListBountyApplicationsOptions holds options for listing applications. -type ListBountyApplicationsOptions struct { - db.ListOptions - BountyID int64 - UserID int64 - Status *ApplicationStatus -} - -func (opts ListBountyApplicationsOptions) ToConds() builder.Cond { - cond := builder.NewCond() - if opts.BountyID > 0 { - cond = cond.And(builder.Eq{"bounty_id": opts.BountyID}) - } - if opts.UserID > 0 { - cond = cond.And(builder.Eq{"user_id": opts.UserID}) - } - if opts.Status != nil { - cond = cond.And(builder.Eq{"status": *opts.Status}) - } - return cond -} - -// ListBountyApplications returns applications matching the given options. -func ListBountyApplications(ctx context.Context, opts ListBountyApplicationsOptions) ([]*BountyApplication, int64, error) { - return db.FindAndCount[BountyApplication](ctx, opts) -} - -// UpdateBountyApplication updates an existing application. -func UpdateBountyApplication(ctx context.Context, app *BountyApplication) error { - _, err := db.GetEngine(ctx).ID(app.ID).AllCols().Update(app) - return err -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-bounty && go test ./models/hackforger/ -run "TestCreateBountyApplication|TestListBountyApplications|TestUpdateBountyApplication" -v -count=1 -tags "bindata sqlite sqlite_unlock_notify"` - -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add models/hackforger/bounty_application.go models/hackforger/bounty_test.go -git commit -m "feat(bounty): add BountyApplication CRUD functions with tests" -``` - ---- - -### Task 4: Complete BountyReward CRUD + Error Types - -**Files:** -- Modify: `models/hackforger/bounty_reward.go` - -- [ ] **Step 1: Write tests for BountyReward CRUD** - -Append to `models/hackforger/bounty_test.go`: - -```go -func TestCreateBountyReward(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - r := &BountyReward{ - BountyID: 2, - Type: RewardTypeCredits, - Credits: 500, - Rank: 2, - } - require.NoError(t, CreateBountyReward(db.DefaultContext, r)) - assert.Greater(t, r.ID, int64(0)) -} - -func TestListBountyRewards(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - rewards, err := ListBountyRewards(db.DefaultContext, 1) - require.NoError(t, err) - assert.Len(t, rewards, 2) // fixture has 2 rewards for bounty 1 -} - -func TestDeleteBountyReward(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - require.NoError(t, DeleteBountyReward(db.DefaultContext, 1)) - - rewards, err := ListBountyRewards(db.DefaultContext, 1) - require.NoError(t, err) - assert.Len(t, rewards, 1) -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-bounty && go test ./models/hackforger/ -run "TestCreateBountyReward|TestListBountyRewards|TestDeleteBountyReward" -v -count=1 -tags "bindata sqlite sqlite_unlock_notify"` - -Expected: FAIL - -- [ ] **Step 3: Implement in bounty_reward.go** - -Add to `models/hackforger/bounty_reward.go`: - -```go -import ( - "context" - "fmt" - - "forgejo.org/models/db" - "forgejo.org/modules/timeutil" - "forgejo.org/modules/util" -) - -// ErrBountyRewardNotExist represents a "BountyRewardNotExist" kind of error. -type ErrBountyRewardNotExist struct { - ID int64 -} - -func IsErrBountyRewardNotExist(err error) bool { - _, ok := err.(ErrBountyRewardNotExist) - return ok -} - -func (err ErrBountyRewardNotExist) Error() string { - return fmt.Sprintf("bounty reward does not exist [id: %d]", err.ID) -} - -func (err ErrBountyRewardNotExist) Unwrap() error { - return util.ErrNotExist -} - -// CreateBountyReward creates a new reward. -func CreateBountyReward(ctx context.Context, r *BountyReward) error { - _, err := db.GetEngine(ctx).Insert(r) - return err -} - -// ListBountyRewards returns all rewards for a bounty, ordered by rank. -func ListBountyRewards(ctx context.Context, bountyID int64) ([]*BountyReward, error) { - var rewards []*BountyReward - err := db.GetEngine(ctx).Where("bounty_id = ?", bountyID).OrderBy("rank ASC").Find(&rewards) - return rewards, err -} - -// DeleteBountyReward deletes a reward by ID. -func DeleteBountyReward(ctx context.Context, id int64) error { - _, err := db.GetEngine(ctx).ID(id).Delete(new(BountyReward)) - return err -} -``` - -- [ ] **Step 4: Run tests to verify pass** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-bounty && go test ./models/hackforger/ -run "TestCreateBountyReward|TestListBountyRewards|TestDeleteBountyReward" -v -count=1 -tags "bindata sqlite sqlite_unlock_notify"` - -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add models/hackforger/bounty_reward.go models/hackforger/bounty_test.go -git commit -m "feat(bounty): add BountyReward CRUD functions and error types" -``` - ---- - -### Task 5: Complete BountyWinner CRUD + More Model Tests - -**Files:** -- Modify: `models/hackforger/bounty_winner.go` - -- [ ] **Step 1: Write tests for BountyWinner + existing Bounty CRUD** - -Append to `models/hackforger/bounty_test.go`: - -```go -func TestCreateBountyWinner(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - w := &BountyWinner{ - BountyID: 2, - UserID: 4, - Rank: 1, - } - require.NoError(t, CreateBountyWinner(db.DefaultContext, w)) - assert.Greater(t, w.ID, int64(0)) -} - -func TestListBountyWinners(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Create a winner first - w := &BountyWinner{BountyID: 2, UserID: 4, Rank: 1} - require.NoError(t, CreateBountyWinner(db.DefaultContext, w)) - - winners, err := ListBountyWinners(db.DefaultContext, 2) - require.NoError(t, err) - assert.Len(t, winners, 1) - assert.Equal(t, int64(4), winners[0].UserID) -} - -func TestCreateBounty(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - b := &Bounty{ - RepoID: 1, - IssueID: 100, // unused issue ID - PublisherID: 2, - Title: "New bounty", - Mode: BountyModeExclusive, - } - require.NoError(t, CreateBounty(db.DefaultContext, b)) - assert.Greater(t, b.ID, int64(0)) - assert.Equal(t, BountyStatusOpen, b.Status) -} - -func TestCreateBounty_DuplicateIssue(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - b := &Bounty{ - RepoID: 1, - IssueID: 1, // already used by fixture bounty 1 - PublisherID: 2, - Title: "duplicate", - } - err := CreateBounty(db.DefaultContext, b) - assert.True(t, IsErrBountyAlreadyExists(err)) -} - -func TestGetBountyByIssueID(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - b, err := GetBountyByIssueID(db.DefaultContext, 1) - require.NoError(t, err) - assert.Equal(t, int64(1), b.ID) - assert.Equal(t, "Fix authentication bug", b.Title) -} - -func TestListBounties_Filter(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - status := BountyStatusOpen - bounties, count, err := ListBounties(db.DefaultContext, ListBountiesOptions{ - RepoID: 1, - Status: &status, - }) - require.NoError(t, err) - assert.Equal(t, int64(1), count) - assert.Len(t, bounties, 1) - assert.Equal(t, BountyStatusOpen, bounties[0].Status) -} -``` - -- [ ] **Step 2: Run to verify failure for winner tests** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-bounty && go test ./models/hackforger/ -run "TestCreateBountyWinner|TestListBountyWinners" -v -count=1 -tags "bindata sqlite sqlite_unlock_notify"` - -Expected: FAIL — functions not defined - -- [ ] **Step 3: Implement in bounty_winner.go** - -Add to `models/hackforger/bounty_winner.go`: - -```go -import ( - "context" - - "forgejo.org/models/db" - "forgejo.org/modules/timeutil" -) - -// CreateBountyWinner creates a new winner record. -func CreateBountyWinner(ctx context.Context, w *BountyWinner) error { - _, err := db.GetEngine(ctx).Insert(w) - return err -} - -// ListBountyWinners returns all winners for a bounty, ordered by rank. -func ListBountyWinners(ctx context.Context, bountyID int64) ([]*BountyWinner, error) { - var winners []*BountyWinner - err := db.GetEngine(ctx).Where("bounty_id = ?", bountyID).OrderBy("rank ASC").Find(&winners) - return winners, err -} -``` - -- [ ] **Step 4: Run all model tests** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-bounty && go test ./models/hackforger/ -v -count=1 -tags "bindata sqlite sqlite_unlock_notify"` - -Expected: ALL PASS - -- [ ] **Step 5: Commit** - -```bash -git add models/hackforger/bounty_winner.go models/hackforger/bounty_test.go -git commit -m "feat(bounty): add BountyWinner CRUD and complete model test suite" -``` - ---- - -### Task 6: Add ActionBountyPaid to action_types.go - -**Files:** -- Modify: `models/hackforger/action_types.go` - -- [ ] **Step 1: Add ActionBountyPaid constant** - -In `models/hackforger/action_types.go`, after `ActionCreditsRedeemed`: - -```go -ActionCreditsRedeemed activities_model.ActionType = 42 -ActionBountyPaid activities_model.ActionType = 43 // entity feed only -``` - -And add to `HackforgerActionTypeName` map: - -```go -ActionBountyPaid: "bounty_paid", -``` - -- [ ] **Step 2: Verify compilation** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-bounty && go build ./models/hackforger/...` - -Expected: no errors - -- [ ] **Step 3: Commit** - -```bash -git add models/hackforger/action_types.go -git commit -m "feat(bounty): add ActionBountyPaid (43) for entity feed tracking" -``` - ---- - -## Chunk 2: Service Layer - -### Task 7: Rewrite Notifier with Bitmask Audience Resolution - -**Files:** -- Modify: `services/hackforger/notifier.go` - -- [ ] **Step 1: Write notifier audience resolution tests** - -Create `services/hackforger/main_test.go`: - -```go -package hackforger - -import ( - "testing" - - "forgejo.org/models/unittest" -) - -func TestMain(m *testing.M) { - unittest.MainTest(m) -} -``` - -Create `services/hackforger/notifier_test.go`: - -```go -package hackforger - -import ( - "testing" - - activities_model "forgejo.org/models/activities" - "forgejo.org/models/db" - hackforger_model "forgejo.org/models/hackforger" - "forgejo.org/models/unittest" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestPublishHackforgerAction_Global(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - err := PublishHackforgerAction(db.DefaultContext, &HackforgerActionOpts{ - ActUserID: 2, - OpType: hackforger_model.ActionBountyCreated, - RepoID: 1, - Content: &hackforger_model.HackforgerActionContent{EntityType: "bounty", EntityID: 1, EntityName: "test"}, - AudienceType: AudienceGlobal, - }) - require.NoError(t, err) - - // Check global record exists (UserID=0) - var actions []*activities_model.Action - err = db.GetEngine(db.DefaultContext).Where("op_type = ? AND user_id = 0", hackforger_model.ActionBountyCreated).Find(&actions) - require.NoError(t, err) - assert.NotEmpty(t, actions) -} - -func TestPublishHackforgerAction_Followers(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // User 4 follows user 2 in Forgejo fixtures - err := PublishHackforgerAction(db.DefaultContext, &HackforgerActionOpts{ - ActUserID: 2, - OpType: hackforger_model.ActionBountyClaimed, - RepoID: 1, - Content: &hackforger_model.HackforgerActionContent{EntityType: "bounty", EntityID: 1, EntityName: "test"}, - AudienceType: AudienceFollowers, - }) - require.NoError(t, err) - - // Actor record should exist - var actorActions []*activities_model.Action - err = db.GetEngine(db.DefaultContext).Where("op_type = ? AND user_id = ? AND act_user_id = ?", - hackforger_model.ActionBountyClaimed, 2, 2).Find(&actorActions) - require.NoError(t, err) - assert.NotEmpty(t, actorActions, "actor's own record should exist") -} - -func TestPublishHackforgerAction_CombinedAudience(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Test bitmask combination: Global | RepoWatchers - err := PublishHackforgerAction(db.DefaultContext, &HackforgerActionOpts{ - ActUserID: 2, - OpType: hackforger_model.ActionBountyCreated, - RepoID: 1, - Content: &hackforger_model.HackforgerActionContent{EntityType: "bounty", EntityID: 1, EntityName: "test"}, - AudienceType: AudienceGlobal | AudienceRepoWatchers, - }) - require.NoError(t, err) - - // Global record - var globalActions []*activities_model.Action - err = db.GetEngine(db.DefaultContext).Where("op_type = ? AND user_id = 0", - hackforger_model.ActionBountyCreated).Find(&globalActions) - require.NoError(t, err) - assert.NotEmpty(t, globalActions) -} - -func TestPublishHackforgerAction_Dedup(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // If same user is both follower and watcher, should only get one record - err := PublishHackforgerAction(db.DefaultContext, &HackforgerActionOpts{ - ActUserID: 2, - OpType: hackforger_model.ActionBountyCreated, - RepoID: 1, - Content: &hackforger_model.HackforgerActionContent{EntityType: "bounty", EntityID: 1, EntityName: "test"}, - AudienceType: AudienceFollowers | AudienceRepoWatchers, - }) - require.NoError(t, err) - // No duplicate insertion errors = pass -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-bounty && go test ./services/hackforger/ -run "TestPublishHackforgerAction" -v -count=1 -tags "bindata sqlite sqlite_unlock_notify"` - -Expected: FAIL — AudienceGlobal value changed, bitmask functions don't exist - -- [ ] **Step 3: Rewrite notifier.go with bitmask audience** - -Replace `services/hackforger/notifier.go` with: - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package hackforger - -import ( - "context" - - activities_model "forgejo.org/models/activities" - "forgejo.org/models/db" - hackforger_model "forgejo.org/models/hackforger" - issues_model "forgejo.org/models/issues" - repo_model "forgejo.org/models/repo" - user_model "forgejo.org/models/user" - "forgejo.org/modules/json" - "forgejo.org/modules/log" - "forgejo.org/modules/timeutil" - notify_service "forgejo.org/services/notify" -) - -type hackforgerNotifier struct { - notify_service.NullNotifier -} - -var _ notify_service.Notifier = &hackforgerNotifier{} - -// Init registers the HackForger notifier. -func Init() error { - notify_service.RegisterNotifier(&hackforgerNotifier{}) - return nil -} - -// AudienceType determines how feed events are distributed (bitmask). -type AudienceType int - -const ( - AudienceGlobal AudienceType = 1 << 0 // 1 — UserID=0, visible to everyone - AudienceFollowers AudienceType = 1 << 1 // 2 — Visible to ActUser's followers - AudienceOrgMembers AudienceType = 1 << 2 // 4 — Visible to org members - AudienceRepoWatchers AudienceType = 1 << 3 // 8 — Visible to repo watchers -) - -// HackforgerActionOpts holds the parameters for publishing a HackForger feed event. -type HackforgerActionOpts struct { - ActUserID int64 - OpType activities_model.ActionType - RepoID int64 - Content any - AudienceType AudienceType - OrgID int64 // used when AudienceType includes AudienceOrgMembers -} - -// PublishHackforgerAction writes HackForger events to the action table -// with full audience resolution using bitmask-based distribution. -func PublishHackforgerAction(ctx context.Context, opts *HackforgerActionOpts) error { - contentBytes, err := json.Marshal(opts.Content) - if err != nil { - return err - } - contentStr := string(contentBytes) - now := timeutil.TimeStampNow() - - // Always insert the actor's own action record - actorAction := &activities_model.Action{ - ActUserID: opts.ActUserID, - UserID: opts.ActUserID, - OpType: opts.OpType, - RepoID: opts.RepoID, - Content: contentStr, - CreatedUnix: now, - } - if _, err := db.GetEngine(ctx).Insert(actorAction); err != nil { - log.Error("PublishHackforgerAction (actor): %v", err) - return err - } - - // Collect target UserIDs with dedup - targets := make(map[int64]bool) - - // Global: insert UserID=0 record - if opts.AudienceType&AudienceGlobal != 0 { - globalAction := &activities_model.Action{ - ActUserID: opts.ActUserID, - UserID: 0, - OpType: opts.OpType, - RepoID: opts.RepoID, - Content: contentStr, - CreatedUnix: now, - } - if _, err := db.GetEngine(ctx).Insert(globalAction); err != nil { - log.Error("PublishHackforgerAction (global): %v", err) - return err - } - } - - // Followers: query user_follow table - if opts.AudienceType&AudienceFollowers != 0 { - var followerIDs []int64 - err := db.GetEngine(ctx).Table("follow"). - Where("follow_id = ?", opts.ActUserID). - Cols("user_id").Find(&followerIDs) - if err != nil { - log.Error("PublishHackforgerAction (followers query): %v", err) - } else { - for _, id := range followerIDs { - targets[id] = true - } - } - } - - // Org members - if opts.AudienceType&AudienceOrgMembers != 0 && opts.OrgID > 0 { - var memberIDs []int64 - err := db.GetEngine(ctx).Table("org_user"). - Where("org_id = ?", opts.OrgID). - Cols("uid").Find(&memberIDs) - if err != nil { - log.Error("PublishHackforgerAction (org members query): %v", err) - } else { - for _, id := range memberIDs { - targets[id] = true - } - } - } - - // Repo watchers - if opts.AudienceType&AudienceRepoWatchers != 0 && opts.RepoID > 0 { - var watcherIDs []int64 - err := db.GetEngine(ctx).Table("watch"). - Where("repo_id = ? AND mode != ?", opts.RepoID, repo_model.WatchModeDont). - Cols("user_id").Find(&watcherIDs) - if err != nil { - log.Error("PublishHackforgerAction (watchers query): %v", err) - } else { - for _, id := range watcherIDs { - targets[id] = true - } - } - } - - // Remove actor (already inserted) and UserID=0 (handled separately) - delete(targets, opts.ActUserID) - delete(targets, 0) - - // Batch insert for all targets - if len(targets) > 0 { - actions := make([]*activities_model.Action, 0, len(targets)) - for uid := range targets { - actions = append(actions, &activities_model.Action{ - ActUserID: opts.ActUserID, - UserID: uid, - OpType: opts.OpType, - RepoID: opts.RepoID, - Content: contentStr, - CreatedUnix: now, - }) - } - if _, err := db.GetEngine(ctx).Insert(actions); err != nil { - log.Error("PublishHackforgerAction (batch targets): %v", err) - return err - } - } - - return nil -} - -// MergePullRequest is called when a PR is merged. If the PR's issue has a -// linked Bounty and the merge author is the Claimer, transition to InReview. -func (n *hackforgerNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { - if err := pr.LoadIssue(ctx); err != nil { - log.Error("hackforgerNotifier.MergePullRequest: LoadIssue: %v", err) - return - } - - bounty, err := hackforger_model.GetBountyByIssueID(ctx, pr.Issue.ID) - if err != nil { - if hackforger_model.IsErrBountyNotExist(err) { - return // no bounty linked — nothing to do - } - log.Error("hackforgerNotifier.MergePullRequest: GetBountyByIssueID: %v", err) - return - } - - if bounty.Status != hackforger_model.BountyStatusClaimed { - return - } - if doer.ID != bounty.ClaimerID { - return - } - - bounty.Status = hackforger_model.BountyStatusInReview - if err := hackforger_model.UpdateBounty(ctx, bounty); err != nil { - log.Error("hackforgerNotifier.MergePullRequest: UpdateBounty: %v", err) - return - } - - if err := PublishHackforgerAction(ctx, &HackforgerActionOpts{ - ActUserID: doer.ID, - OpType: hackforger_model.ActionBountyDelivered, - RepoID: bounty.RepoID, - Content: &hackforger_model.HackforgerActionContent{EntityType: "bounty", EntityID: bounty.ID, EntityName: bounty.Title}, - AudienceType: AudienceFollowers | AudienceRepoWatchers, - }); err != nil { - log.Error("hackforgerNotifier.MergePullRequest: PublishHackforgerAction: %v", err) - } -} -``` - -- [ ] **Step 4: Run notifier tests** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-bounty && go test ./services/hackforger/ -run "TestPublishHackforgerAction" -v -count=1 -tags "bindata sqlite sqlite_unlock_notify"` - -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add services/hackforger/notifier.go services/hackforger/notifier_test.go services/hackforger/main_test.go -git commit -m "feat(notifier): rewrite PublishHackforgerAction with bitmask audience resolution - -Supports 4 audience types via bitmask composition: Global, Followers, -OrgMembers, RepoWatchers. Includes dedup and batch insert. -Also implements MergePullRequest hook for Bounty-PR linkage." -``` - ---- - -### Task 8: Implement Bounty Service State Machine - -**Files:** -- Create: `services/hackforger/bounty.go` -- Create: `services/hackforger/bounty_test.go` - -- [ ] **Step 1: Write service tests** - -Create `services/hackforger/bounty_test.go`: - -```go -package hackforger - -import ( - "testing" - - "forgejo.org/models/db" - hackforger_model "forgejo.org/models/hackforger" - "forgejo.org/models/unittest" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestApplyForBounty(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - err := ApplyForBounty(db.DefaultContext, 1, 5, "I can do this") - require.NoError(t, err) - - apps, _, err := hackforger_model.ListBountyApplications(db.DefaultContext, hackforger_model.ListBountyApplicationsOptions{ - BountyID: 1, - UserID: 5, - }) - require.NoError(t, err) - assert.Len(t, apps, 1) -} - -func TestApplyForBounty_NotOpen(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Bounty 2 is Claimed (status=1) - err := ApplyForBounty(db.DefaultContext, 2, 5, "too late") - assert.Error(t, err) -} - -func TestAcceptApplication_Exclusive(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Accept application 1 (user 4 for bounty 1, exclusive mode) - err := AcceptApplication(db.DefaultContext, 1, 2) // doer=2 is publisher - require.NoError(t, err) - - bounty, err := hackforger_model.GetBountyByID(db.DefaultContext, 1) - require.NoError(t, err) - assert.Equal(t, hackforger_model.BountyStatusClaimed, bounty.Status) - assert.Equal(t, int64(4), bounty.ClaimerID) -} - -func TestAcceptApplication_NotPublisher(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - err := AcceptApplication(db.DefaultContext, 1, 5) // doer=5 is NOT publisher - assert.Error(t, err) -} - -func TestCancelBounty(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - err := CancelBounty(db.DefaultContext, 1, 2) // publisher cancels - require.NoError(t, err) - - bounty, err := hackforger_model.GetBountyByID(db.DefaultContext, 1) - require.NoError(t, err) - assert.Equal(t, hackforger_model.BountyStatusCancelled, bounty.Status) -} - -func TestCancelBounty_NotPublisher(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - err := CancelBounty(db.DefaultContext, 1, 5) - assert.Error(t, err) -} - -func TestCompleteBounty(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Set bounty 1 to InReview state first - b, _ := hackforger_model.GetBountyByID(db.DefaultContext, 1) - b.Status = hackforger_model.BountyStatusInReview - b.ClaimerID = 4 - require.NoError(t, hackforger_model.UpdateBounty(db.DefaultContext, b)) - - err := CompleteBounty(db.DefaultContext, 1, 2) // publisher completes - require.NoError(t, err) - - bounty, err := hackforger_model.GetBountyByID(db.DefaultContext, 1) - require.NoError(t, err) - assert.Equal(t, hackforger_model.BountyStatusCompleted, bounty.Status) -} - -func TestRejectDelivery(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Set bounty 1 to InReview - b, _ := hackforger_model.GetBountyByID(db.DefaultContext, 1) - b.Status = hackforger_model.BountyStatusInReview - b.ClaimerID = 4 - require.NoError(t, hackforger_model.UpdateBounty(db.DefaultContext, b)) - - err := RejectDelivery(db.DefaultContext, 1, 2) - require.NoError(t, err) - - bounty, err := hackforger_model.GetBountyByID(db.DefaultContext, 1) - require.NoError(t, err) - assert.Equal(t, hackforger_model.BountyStatusClaimed, bounty.Status) -} - -func TestMarkPaid(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - b, _ := hackforger_model.GetBountyByID(db.DefaultContext, 1) - b.Status = hackforger_model.BountyStatusCompleted - require.NoError(t, hackforger_model.UpdateBounty(db.DefaultContext, b)) - - err := MarkPaid(db.DefaultContext, 1, 2) - require.NoError(t, err) - - bounty, err := hackforger_model.GetBountyByID(db.DefaultContext, 1) - require.NoError(t, err) - assert.Equal(t, hackforger_model.BountyStatusPaid, bounty.Status) -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-bounty && go test ./services/hackforger/ -run "TestApplyForBounty|TestAcceptApplication|TestCancelBounty|TestCompleteBounty|TestRejectDelivery|TestMarkPaid" -v -count=1 -tags "bindata sqlite sqlite_unlock_notify"` - -Expected: FAIL - -- [ ] **Step 3: Implement bounty service** - -Create `services/hackforger/bounty.go`: - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package hackforger - -import ( - "context" - "fmt" - - "forgejo.org/models/db" - hackforger_model "forgejo.org/models/hackforger" - "forgejo.org/modules/timeutil" -) - -// ErrInvalidBountyStatus is returned when a state transition is not allowed. -type ErrInvalidBountyStatus struct { - BountyID int64 - Current hackforger_model.BountyStatus - Target hackforger_model.BountyStatus -} - -func (e ErrInvalidBountyStatus) Error() string { - return fmt.Sprintf("invalid bounty status transition [bounty: %d, %d → %d]", e.BountyID, e.Current, e.Target) -} - -// ErrNotPublisher is returned when a non-publisher tries a publisher-only action. -type ErrNotPublisher struct { - BountyID int64 - DoerID int64 -} - -func (e ErrNotPublisher) Error() string { - return fmt.Sprintf("user %d is not the publisher of bounty %d", e.DoerID, e.BountyID) -} - -func requirePublisher(bounty *hackforger_model.Bounty, doerID int64) error { - if bounty.PublisherID != doerID { - return ErrNotPublisher{BountyID: bounty.ID, DoerID: doerID} - } - return nil -} - -// ApplyForBounty creates an application for a bounty. -func ApplyForBounty(ctx context.Context, bountyID, userID int64, message string) error { - bounty, err := hackforger_model.GetBountyByID(ctx, bountyID) - if err != nil { - return err - } - if bounty.Status != hackforger_model.BountyStatusOpen { - return ErrInvalidBountyStatus{BountyID: bountyID, Current: bounty.Status, Target: hackforger_model.BountyStatusOpen} - } - - return hackforger_model.CreateBountyApplication(ctx, &hackforger_model.BountyApplication{ - BountyID: bountyID, - UserID: userID, - Message: message, - }) -} - -// AcceptApplication accepts an application. For Exclusive mode, rejects all -// other pending applications and sets Bounty to Claimed. -func AcceptApplication(ctx context.Context, applicationID, doerID int64) error { - app, err := hackforger_model.GetBountyApplicationByID(ctx, applicationID) - if err != nil { - return err - } - - bounty, err := hackforger_model.GetBountyByID(ctx, app.BountyID) - if err != nil { - return err - } - if err := requirePublisher(bounty, doerID); err != nil { - return err - } - - return db.WithTx(ctx, func(ctx context.Context) error { - app.Status = hackforger_model.ApplicationStatusAccepted - if err := hackforger_model.UpdateBountyApplication(ctx, app); err != nil { - return err - } - - if bounty.Mode == hackforger_model.BountyModeExclusive { - // Reject all other pending applications - pending := hackforger_model.ApplicationStatusPending - others, _, err := hackforger_model.ListBountyApplications(ctx, hackforger_model.ListBountyApplicationsOptions{ - BountyID: bounty.ID, - Status: &pending, - }) - if err != nil { - return err - } - for _, other := range others { - if other.ID == app.ID { - continue - } - other.Status = hackforger_model.ApplicationStatusRejected - if err := hackforger_model.UpdateBountyApplication(ctx, other); err != nil { - return err - } - } - - bounty.Status = hackforger_model.BountyStatusClaimed - bounty.ClaimerID = app.UserID - if err := hackforger_model.UpdateBounty(ctx, bounty); err != nil { - return err - } - - return PublishHackforgerAction(ctx, &HackforgerActionOpts{ - ActUserID: app.UserID, - OpType: hackforger_model.ActionBountyClaimed, - RepoID: bounty.RepoID, - Content: &hackforger_model.HackforgerActionContent{EntityType: "bounty", EntityID: bounty.ID, EntityName: bounty.Title}, - AudienceType: AudienceFollowers | AudienceRepoWatchers, - }) - } - - // Competitive: just accept, no status change - return nil - }) -} - -// RejectApplication rejects an application. -func RejectApplication(ctx context.Context, applicationID, doerID int64) error { - app, err := hackforger_model.GetBountyApplicationByID(ctx, applicationID) - if err != nil { - return err - } - bounty, err := hackforger_model.GetBountyByID(ctx, app.BountyID) - if err != nil { - return err - } - if err := requirePublisher(bounty, doerID); err != nil { - return err - } - app.Status = hackforger_model.ApplicationStatusRejected - return hackforger_model.UpdateBountyApplication(ctx, app) -} - -// StartReview transitions a Competitive bounty from Open to InReview. -func StartReview(ctx context.Context, bountyID, doerID int64) error { - bounty, err := hackforger_model.GetBountyByID(ctx, bountyID) - if err != nil { - return err - } - if err := requirePublisher(bounty, doerID); err != nil { - return err - } - if bounty.Status != hackforger_model.BountyStatusOpen || bounty.Mode != hackforger_model.BountyModeCompetitive { - return ErrInvalidBountyStatus{BountyID: bountyID, Current: bounty.Status, Target: hackforger_model.BountyStatusInReview} - } - bounty.Status = hackforger_model.BountyStatusInReview - return hackforger_model.UpdateBounty(ctx, bounty) -} - -// CompleteBounty verifies delivery and transitions to Completed. -// If there are credits-type rewards, deposits them to the claimer. -func CompleteBounty(ctx context.Context, bountyID, doerID int64) error { - bounty, err := hackforger_model.GetBountyByID(ctx, bountyID) - if err != nil { - return err - } - if err := requirePublisher(bounty, doerID); err != nil { - return err - } - if bounty.Status != hackforger_model.BountyStatusInReview { - return ErrInvalidBountyStatus{BountyID: bountyID, Current: bounty.Status, Target: hackforger_model.BountyStatusCompleted} - } - - return db.WithTx(ctx, func(ctx context.Context) error { - bounty.Status = hackforger_model.BountyStatusCompleted - if err := hackforger_model.UpdateBounty(ctx, bounty); err != nil { - return err - } - - // Deposit credits rewards to claimer - if bounty.ClaimerID > 0 { - rewards, err := hackforger_model.ListBountyRewards(ctx, bounty.ID) - if err != nil { - return err - } - for _, r := range rewards { - if r.Type == hackforger_model.RewardTypeCredits && r.Credits > 0 { - if err := Deposit(ctx, bounty.ClaimerID, r.Credits, - fmt.Sprintf("bounty:%d", bounty.ID), "Bounty reward"); err != nil { - return err - } - } - } - } - - return PublishHackforgerAction(ctx, &HackforgerActionOpts{ - ActUserID: doerID, - OpType: hackforger_model.ActionBountyCompleted, - RepoID: bounty.RepoID, - Content: &hackforger_model.HackforgerActionContent{EntityType: "bounty", EntityID: bounty.ID, EntityName: bounty.Title}, - AudienceType: AudienceFollowers | AudienceRepoWatchers, - }) - }) -} - -// RejectDelivery returns an Exclusive bounty from InReview to Claimed. -func RejectDelivery(ctx context.Context, bountyID, doerID int64) error { - bounty, err := hackforger_model.GetBountyByID(ctx, bountyID) - if err != nil { - return err - } - if err := requirePublisher(bounty, doerID); err != nil { - return err - } - if bounty.Status != hackforger_model.BountyStatusInReview || bounty.Mode != hackforger_model.BountyModeExclusive { - return ErrInvalidBountyStatus{BountyID: bountyID, Current: bounty.Status, Target: hackforger_model.BountyStatusClaimed} - } - bounty.Status = hackforger_model.BountyStatusClaimed - return hackforger_model.UpdateBounty(ctx, bounty) -} - -// WinnerInput holds the data for selecting a winner. -type WinnerInput struct { - UserID int64 - Rank int -} - -// SelectWinners selects winners for a Competitive bounty, deposits ranked credits. -func SelectWinners(ctx context.Context, bountyID, doerID int64, winners []WinnerInput) error { - bounty, err := hackforger_model.GetBountyByID(ctx, bountyID) - if err != nil { - return err - } - if err := requirePublisher(bounty, doerID); err != nil { - return err - } - if bounty.Status != hackforger_model.BountyStatusInReview || bounty.Mode != hackforger_model.BountyModeCompetitive { - return ErrInvalidBountyStatus{BountyID: bountyID, Current: bounty.Status, Target: hackforger_model.BountyStatusCompleted} - } - - return db.WithTx(ctx, func(ctx context.Context) error { - rewards, err := hackforger_model.ListBountyRewards(ctx, bounty.ID) - if err != nil { - return err - } - rewardByRank := make(map[int]*hackforger_model.BountyReward) - for _, r := range rewards { - rewardByRank[r.Rank] = r - } - - for _, w := range winners { - if err := hackforger_model.CreateBountyWinner(ctx, &hackforger_model.BountyWinner{ - BountyID: bountyID, - UserID: w.UserID, - Rank: w.Rank, - }); err != nil { - return err - } - - if r, ok := rewardByRank[w.Rank]; ok && r.Type == hackforger_model.RewardTypeCredits && r.Credits > 0 { - if err := Deposit(ctx, w.UserID, r.Credits, - fmt.Sprintf("bounty:%d:rank:%d", bounty.ID, w.Rank), "Bounty winner reward"); err != nil { - return err - } - } - } - - bounty.Status = hackforger_model.BountyStatusCompleted - if err := hackforger_model.UpdateBounty(ctx, bounty); err != nil { - return err - } - - return PublishHackforgerAction(ctx, &HackforgerActionOpts{ - ActUserID: doerID, - OpType: hackforger_model.ActionBountyWinnersSelected, - RepoID: bounty.RepoID, - Content: &hackforger_model.HackforgerActionContent{EntityType: "bounty", EntityID: bounty.ID, EntityName: bounty.Title}, - AudienceType: AudienceGlobal, - }) - }) -} - -// MarkPaid transitions a Completed bounty to Paid. -func MarkPaid(ctx context.Context, bountyID, doerID int64) error { - bounty, err := hackforger_model.GetBountyByID(ctx, bountyID) - if err != nil { - return err - } - if err := requirePublisher(bounty, doerID); err != nil { - return err - } - if bounty.Status != hackforger_model.BountyStatusCompleted { - return ErrInvalidBountyStatus{BountyID: bountyID, Current: bounty.Status, Target: hackforger_model.BountyStatusPaid} - } - - bounty.Status = hackforger_model.BountyStatusPaid - if err := hackforger_model.UpdateBounty(ctx, bounty); err != nil { - return err - } - - // Entity-feed-only: AudienceType=0 means only actor record - return PublishHackforgerAction(ctx, &HackforgerActionOpts{ - ActUserID: doerID, - OpType: hackforger_model.ActionBountyPaid, - RepoID: bounty.RepoID, - Content: &hackforger_model.HackforgerActionContent{EntityType: "bounty", EntityID: bounty.ID, EntityName: bounty.Title}, - AudienceType: 0, - }) -} - -// CancelBounty cancels a bounty (allowed from Open or Claimed). -func CancelBounty(ctx context.Context, bountyID, doerID int64) error { - bounty, err := hackforger_model.GetBountyByID(ctx, bountyID) - if err != nil { - return err - } - if err := requirePublisher(bounty, doerID); err != nil { - return err - } - if bounty.Status != hackforger_model.BountyStatusOpen && bounty.Status != hackforger_model.BountyStatusClaimed { - return ErrInvalidBountyStatus{BountyID: bountyID, Current: bounty.Status, Target: hackforger_model.BountyStatusCancelled} - } - - bounty.Status = hackforger_model.BountyStatusCancelled - if err := hackforger_model.UpdateBounty(ctx, bounty); err != nil { - return err - } - - return PublishHackforgerAction(ctx, &HackforgerActionOpts{ - ActUserID: doerID, - OpType: hackforger_model.ActionBountyCancelled, - RepoID: bounty.RepoID, - Content: &hackforger_model.HackforgerActionContent{EntityType: "bounty", EntityID: bounty.ID, EntityName: bounty.Title}, - AudienceType: AudienceRepoWatchers, - }) -} - -// CheckExpiredBounties checks for bounties that have passed their deadline -// and marks them as Expired. -func CheckExpiredBounties(ctx context.Context) error { - now := timeutil.TimeStampNow() - var bounties []*hackforger_model.Bounty - err := db.GetEngine(ctx). - Where("deadline > 0 AND deadline < ? AND status IN (?, ?)", - now, hackforger_model.BountyStatusOpen, hackforger_model.BountyStatusClaimed). - Find(&bounties) - if err != nil { - return err - } - - for _, b := range bounties { - b.Status = hackforger_model.BountyStatusExpired - if err := hackforger_model.UpdateBounty(ctx, b); err != nil { - return err - } - _ = PublishHackforgerAction(ctx, &HackforgerActionOpts{ - ActUserID: b.PublisherID, - OpType: hackforger_model.ActionBountyExpired, - RepoID: b.RepoID, - Content: &hackforger_model.HackforgerActionContent{EntityType: "bounty", EntityID: b.ID, EntityName: b.Title}, - AudienceType: AudienceRepoWatchers, - }) - } - return nil -} - -// UpdateBountyMeta updates bounty title and deadline. Only allowed when Open. -func UpdateBountyMeta(ctx context.Context, bountyID, doerID int64, title string, deadline timeutil.TimeStamp) error { - bounty, err := hackforger_model.GetBountyByID(ctx, bountyID) - if err != nil { - return err - } - if err := requirePublisher(bounty, doerID); err != nil { - return err - } - if bounty.Status != hackforger_model.BountyStatusOpen { - return ErrInvalidBountyStatus{BountyID: bountyID, Current: bounty.Status, Target: hackforger_model.BountyStatusOpen} - } - if title != "" { - bounty.Title = title - } - bounty.Deadline = deadline - return hackforger_model.UpdateBounty(ctx, bounty) -} - -// DeleteBounty deletes a bounty. Only allowed when Open with no applications. -func DeleteBounty(ctx context.Context, bountyID, doerID int64) error { - bounty, err := hackforger_model.GetBountyByID(ctx, bountyID) - if err != nil { - return err - } - if err := requirePublisher(bounty, doerID); err != nil { - return err - } - if bounty.Status != hackforger_model.BountyStatusOpen { - return ErrInvalidBountyStatus{BountyID: bountyID, Current: bounty.Status, Target: hackforger_model.BountyStatusOpen} - } - - apps, count, err := hackforger_model.ListBountyApplications(ctx, hackforger_model.ListBountyApplicationsOptions{ - BountyID: bountyID, - }) - if err != nil { - return err - } - _ = apps - if count > 0 { - return fmt.Errorf("cannot delete bounty with existing applications [bounty_id: %d, count: %d]", bountyID, count) - } - - return hackforger_model.DeleteBounty(ctx, bountyID) -} -``` - -- [ ] **Step 4: Run service tests** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-bounty && go test ./services/hackforger/ -run "TestApplyForBounty|TestAcceptApplication|TestCancelBounty|TestCompleteBounty|TestRejectDelivery|TestMarkPaid" -v -count=1 -tags "bindata sqlite sqlite_unlock_notify"` - -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add services/hackforger/bounty.go services/hackforger/bounty_test.go -git commit -m "feat(bounty): implement Bounty service state machine - -Includes: ApplyForBounty, AcceptApplication, RejectApplication, -StartReview, CompleteBounty, RejectDelivery, SelectWinners, -MarkPaid, CancelBounty, CheckExpiredBounties, UpdateBountyMeta, -DeleteBounty. All state changes publish feed events." -``` - ---- - -## Chunk 3: API Layer - -### Task 9: Implement Bounty API Handlers - -**Files:** -- Modify: `routers/api/v1/hackforger/bounty.go` (replace skeleton) -- Modify: `routers/api/v1/api.go` (register routes) - -- [ ] **Step 1: Implement full API handlers** - -Replace `routers/api/v1/hackforger/bounty.go` with full implementation. This is the largest single file (~650 lines). Key handlers: - -- `CreateBounty` — POST, requires repo writer, calls `hackforger_model.CreateBounty` + publishes ActionBountyCreated -- `ListRepoBounties` — GET, public, calls `hackforger_model.ListBounties` with repo filter -- `GetBounty` — GET, public, calls `hackforger_model.GetBountyByID` -- `UpdateBounty` — PUT, publisher only, calls `hackforger_svc.UpdateBountyMeta` -- `DeleteBounty` — DELETE, publisher only, calls `hackforger_svc.DeleteBounty` -- `AddReward` — POST, publisher only -- `ListRewards` — GET, public -- `DeleteReward` — DELETE, publisher + Open only -- `ApplyForBounty` — POST, any user, calls `hackforger_svc.ApplyForBounty` -- `ListApplications` — GET, publisher only -- `ReviewApplication` — PUT, publisher, calls Accept/Reject -- `StartReview` — POST, publisher, competitive only -- `CompleteBounty` — POST, publisher, calls `hackforger_svc.CompleteBounty` -- `RejectDelivery` — POST, publisher, exclusive only -- `MarkPaid` — POST, publisher -- `CancelBounty` — POST, publisher -- `ExpireBounty` — POST, publisher/admin -- `SelectWinners` — POST, publisher, competitive -- `ListWinners` — GET, public -- `ListAllBounties` — GET, global list (replace P0 skeleton) -- `BountyStats` — GET, aggregate stats -- `HunterLeaderboard` — GET, ranked hunters - -Each handler follows Forgejo's pattern: Swagger annotation comment → parameter parsing → service call → error handling → JSON response. - -- [ ] **Step 2: Register routes in api.go** - -Add to the hackforger group in `routers/api/v1/api.go`: - -```go -// HackForger API routes -m.Group("/hackforger", func() { - m.Get("/hackathons", hackforger_api.ListHackathons) - m.Get("/bounties", hackforger_api.ListAllBounties) - m.Get("/bounties/stats", hackforger_api.BountyStats) - m.Get("/bounties/leaderboard", hackforger_api.HunterLeaderboard) - m.Get("/grants/rounds", hackforger_api.ListGrantRounds) - m.Get("/credits/balance", hackforger_api.GetBalance) - m.Get("/feed", hackforger_api.GetFeed) -}) -``` - -Add repo-level bounty routes inside the existing `/{username}/{reponame}` group: - -```go -// HackForger Bounty routes (repo-level) -m.Group("/bounties", func() { - m.Get("", hackforger_api.ListRepoBounties) - m.Post("", reqToken(), mustNotBeArchived, hackforger_api.CreateBounty) - m.Group("/{bounty_id}", func() { - m.Get("", hackforger_api.GetBounty) - m.Put("", reqToken(), hackforger_api.UpdateBounty) - m.Delete("", reqToken(), hackforger_api.DeleteBounty) - m.Get("/rewards", hackforger_api.ListRewards) - m.Post("/rewards", reqToken(), hackforger_api.AddReward) - m.Delete("/rewards/{reward_id}", reqToken(), hackforger_api.DeleteReward) - m.Get("/applications", reqToken(), hackforger_api.ListApplications) - m.Post("/applications", reqToken(), hackforger_api.ApplyForBounty) - m.Put("/applications/{application_id}", reqToken(), hackforger_api.ReviewApplication) - m.Post("/start-review", reqToken(), hackforger_api.StartReview) - m.Post("/complete", reqToken(), hackforger_api.CompleteBounty) - m.Post("/reject-delivery", reqToken(), hackforger_api.RejectDelivery) - m.Post("/pay", reqToken(), hackforger_api.MarkPaid) - m.Post("/cancel", reqToken(), hackforger_api.CancelBounty) - m.Post("/expire", reqToken(), hackforger_api.ExpireBounty) - m.Post("/winners", reqToken(), hackforger_api.SelectWinners) - m.Get("/winners", hackforger_api.ListWinners) - }) -}) -``` - -- [ ] **Step 3: Verify compilation** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-bounty && go build ./routers/...` - -Expected: no errors - -- [ ] **Step 4: Commit** - -```bash -git add routers/api/v1/hackforger/bounty.go routers/api/v1/api.go -git commit -m "feat(bounty): implement full Bounty REST API - -20 endpoints: repo-level CRUD + applications + state transitions + -global list/stats/leaderboard. All with Swagger annotations." -``` - ---- - -## Chunk 4: Web Routes + Templates + Frontend - -### Task 10: Web Routes and Explore Page - -**Files:** -- Create: `routers/web/hackforger/bounty.go` -- Create: `templates/hackforger/bounty/explore.tmpl` -- Modify: `routers/web/hackforger/hackathon.go` (remove ExploreBounties) -- Modify: `routers/web/web.go` (register new routes) -- Modify: `options/locale/locale_en-US.ini` (add i18n keys) - -- [ ] **Step 1: Create bounty web handler** - -Create `routers/web/hackforger/bounty.go`: - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package hackforger - -import ( - "net/http" - - hackforger_model "forgejo.org/models/hackforger" - "forgejo.org/models/db" - "forgejo.org/services/context" -) - -const tplBountyExplore = "hackforger/bounty/explore" -const tplBountyNew = "hackforger/bounty/new" - -// ExploreBounties renders the bounty explore page. -func ExploreBounties(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("hackforger.explore.bounties") - ctx.Data["PageIsExploreBounties"] = true - - page := ctx.FormInt("page") - if page <= 0 { - page = 1 - } - - var statusFilter *hackforger_model.BountyStatus - if s := ctx.FormString("status"); s != "" { - switch s { - case "open": - st := hackforger_model.BountyStatusOpen; statusFilter = &st - case "claimed": - st := hackforger_model.BountyStatusClaimed; statusFilter = &st - case "in_review": - st := hackforger_model.BountyStatusInReview; statusFilter = &st - case "completed": - st := hackforger_model.BountyStatusCompleted; statusFilter = &st - } - } - - bounties, total, err := hackforger_model.ListBounties(ctx, hackforger_model.ListBountiesOptions{ - ListOptions: db.ListOptions{Page: page, PageSize: 20}, - Status: statusFilter, - }) - if err != nil { - ctx.ServerError("ListBounties", err) - return - } - - ctx.Data["Bounties"] = bounties - ctx.Data["Total"] = total - ctx.Data["StatusFilter"] = ctx.FormString("status") - - pager := context.NewPagination(int(total), 20, page, 5) - pager.SetDefaultParams(ctx) - ctx.Data["Page"] = pager - - ctx.HTML(http.StatusOK, tplBountyExplore) -} -``` - -- [ ] **Step 2: Move ExploreBounties out of hackathon.go** - -In `routers/web/hackforger/hackathon.go`, remove the `ExploreBounties` function (it's now in `bounty.go`). - -- [ ] **Step 3: Create explore template** - -Create `templates/hackforger/bounty/explore.tmpl`: - -```html -{{template "base/head" .}} -
- {{template "explore/navbar" .}} -
- - - {{if .Bounties}} -
- {{range .Bounties}} -
-
- {{.Title}} -
- - {{if eq .Status 0}}{{ctx.Locale.Tr "hackforger.bounty.status.open"}} - {{else if eq .Status 1}}{{ctx.Locale.Tr "hackforger.bounty.status.claimed"}} - {{else if eq .Status 2}}{{ctx.Locale.Tr "hackforger.bounty.status.in_review"}} - {{else if eq .Status 3}}{{ctx.Locale.Tr "hackforger.bounty.status.completed"}} - {{else if eq .Status 4}}{{ctx.Locale.Tr "hackforger.bounty.status.paid"}} - {{else if eq .Status 5}}{{ctx.Locale.Tr "hackforger.bounty.status.expired"}} - {{else if eq .Status 6}}{{ctx.Locale.Tr "hackforger.bounty.status.cancelled"}} - {{end}} - -
-
-
- {{end}} -
- {{template "base/paginate" .}} - {{else}} -
-
- {{svg "octicon-gift" 48}} -
- {{ctx.Locale.Tr "hackforger.bounty.explore.empty"}} -
-
- {{end}} -
-
-{{template "base/footer" .}} -``` - -- [ ] **Step 4: Add i18n keys** - -Append to `options/locale/locale_en-US.ini` under the `[hackforger]` section: - -```ini -; Bounty explore -hackforger.bounty.filter.all = All -hackforger.bounty.explore.empty = No bounties yet. Create one from a repository issue! -hackforger.bounty.explore.title = Explore Bounties - -; Bounty panel -hackforger.bounty.panel.title = Bounty -hackforger.bounty.panel.rewards = Rewards -hackforger.bounty.panel.deadline = Deadline -hackforger.bounty.panel.claimer = Claimed by -hackforger.bounty.panel.apply = Apply -hackforger.bounty.panel.apply.message = Why should you be assigned? -hackforger.bounty.panel.applications = Applications -hackforger.bounty.panel.accept = Accept -hackforger.bounty.panel.reject = Reject -hackforger.bounty.panel.complete = Complete -hackforger.bounty.panel.reject_delivery = Reject Delivery -hackforger.bounty.panel.mark_paid = Mark Paid -hackforger.bounty.panel.cancel = Cancel Bounty -hackforger.bounty.panel.start_review = Start Review -hackforger.bounty.panel.select_winners = Select Winners -hackforger.bounty.panel.no_applications = No applications yet - -; Bounty create form -hackforger.bounty.new.title = Create Bounty -hackforger.bounty.new.issue = Issue -hackforger.bounty.new.mode = Mode -hackforger.bounty.new.mode.exclusive = Exclusive (one person claims and delivers) -hackforger.bounty.new.mode.competitive = Competitive (multiple entries, select winners) -hackforger.bounty.new.deadline = Deadline -hackforger.bounty.new.submit = Create Bounty - -; Bounty badge -hackforger.bounty.badge.bounty = Bounty -``` - -- [ ] **Step 5: Register web route** - -In `routers/web/web.go`, update the explore section. The P0 registration already calls `hackforger_web.ExploreBounties` from the explore group. Since we moved it to `bounty.go` in the same package, no route change needed — just verify it compiles. - -- [ ] **Step 6: Verify compilation and page renders** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-bounty && TAGS="bindata sqlite sqlite_unlock_notify" make backend` - -Expected: no errors - -- [ ] **Step 7: Commit** - -```bash -git add routers/web/hackforger/bounty.go routers/web/hackforger/hackathon.go templates/hackforger/bounty/explore.tmpl options/locale/locale_en-US.ini -git commit -m "feat(bounty): add Bounty explore page with status filters - -Replace P0 placeholder with full bounty listing page. -Add i18n keys for bounty UI." -``` - ---- - -### Task 11: Issue Template Injections (Panel + Badge) - -**Files:** -- Create: `templates/hackforger/bounty/panel.tmpl` -- Create: `templates/hackforger/bounty/badge.tmpl` -- Modify: `templates/repo/issue/view_content/sidebar.tmpl` -- Modify: `templates/shared/issuelist.tmpl` -- Modify: `routers/web/repo/issue.go` - -- [ ] **Step 1: Create panel template** - -Create `templates/hackforger/bounty/panel.tmpl`: - -```html -{{if .BountyData}} -
-
-
- {{svg "octicon-gift" 16}} {{ctx.Locale.Tr "hackforger.bounty.panel.title"}} - - {{if eq .BountyData.Status 0}}{{ctx.Locale.Tr "hackforger.bounty.status.open"}} - {{else if eq .BountyData.Status 1}}{{ctx.Locale.Tr "hackforger.bounty.status.claimed"}} - {{else if eq .BountyData.Status 2}}{{ctx.Locale.Tr "hackforger.bounty.status.in_review"}} - {{else if eq .BountyData.Status 3}}{{ctx.Locale.Tr "hackforger.bounty.status.completed"}} - {{else if eq .BountyData.Status 4}}{{ctx.Locale.Tr "hackforger.bounty.status.paid"}} - {{else if eq .BountyData.Status 5}}{{ctx.Locale.Tr "hackforger.bounty.status.expired"}} - {{else if eq .BountyData.Status 6}}{{ctx.Locale.Tr "hackforger.bounty.status.cancelled"}} - {{end}} - -
- - {{if .BountyRewards}} -
- {{ctx.Locale.Tr "hackforger.bounty.panel.rewards"}}: - {{range .BountyRewards}} - - {{if eq .Type "money"}}{{.Currency}} {{.Amount}}{{end}} - {{if eq .Type "credits"}}{{.Credits}} credits{{end}} - {{if eq .Type "other"}}{{.Note}}{{end}} - - {{end}} -
- {{end}} - - {{if .BountyData.Deadline}} -
- {{ctx.Locale.Tr "hackforger.bounty.panel.deadline"}}: - {{DateTime "long" .BountyData.Deadline}} -
- {{end}} - -
-
-
-{{end}} -``` - -- [ ] **Step 2: Create badge template** - -Create `templates/hackforger/bounty/badge.tmpl`: - -```html -{{if .BountyBadge}} - - {{svg "octicon-gift" 12}} {{ctx.Locale.Tr "hackforger.bounty.badge.bounty"}} - -{{end}} -``` - -- [ ] **Step 3: Inject panel in issue sidebar** - -In `templates/repo/issue/view_content/sidebar.tmpl`, after the assignees section and before the participants section, add: - -```html -{{template "hackforger/bounty/panel" .}} -``` - -- [ ] **Step 4: Inject badge in issue list** - -In `templates/shared/issuelist.tmpl`, after the issue title `` tag and before the commit statuses, add: - -```html -{{if .BountyBadge}}{{template "hackforger/bounty/badge" dict "BountyBadge" .BountyBadge}}{{end}} -``` - -- [ ] **Step 5: Load bounty data in issue handler** - -In `routers/web/repo/issue.go` ViewIssue function, after loading the issue and before rendering, add: - -```go -// Load HackForger Bounty data if linked -bountyData, err := hackforger_model.GetBountyByIssueID(ctx, issue.ID) -if err != nil && !hackforger_model.IsErrBountyNotExist(err) { - ctx.ServerError("GetBountyByIssueID", err) - return -} -if bountyData != nil { - ctx.Data["BountyData"] = bountyData - rewards, err := hackforger_model.ListBountyRewards(ctx, bountyData.ID) - if err != nil { - ctx.ServerError("ListBountyRewards", err) - return - } - ctx.Data["BountyRewards"] = rewards -} -``` - -For the issue list handler, add batch bounty loading (query bounties for all displayed issue IDs and attach as `.BountyBadge` on each issue). - -- [ ] **Step 6: Verify compilation** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-bounty && TAGS="bindata sqlite sqlite_unlock_notify" make backend` - -Expected: no errors - -- [ ] **Step 7: Commit** - -```bash -git add templates/hackforger/bounty/panel.tmpl templates/hackforger/bounty/badge.tmpl templates/repo/issue/view_content/sidebar.tmpl templates/shared/issuelist.tmpl routers/web/repo/issue.go -git commit -m "feat(bounty): inject Bounty panel in Issue sidebar and badge in Issue list - -Two upstream template changes (~3 lines each) plus data loading in -ViewIssue handler." -``` - ---- - -### Task 12: Vue BountyPanel Component - -**Files:** -- Create/Replace: `web_src/js/components/hackforger/BountyPanel.vue` -- Verify: `web_src/js/features/hackforger/init.js` (already has mount code) - -- [ ] **Step 1: Implement BountyPanel.vue** - -This Vue component handles interactive actions (apply, accept/reject, complete, etc.) using Forgejo's fetch API. It reads initial state from `data-*` attributes passed as props, calls Bounty API endpoints, and updates the panel locally without full page reload. - -Key features: -- Shows application form (for non-publisher users on Open bounties) -- Shows application list with accept/reject buttons (for publisher) -- Shows complete/reject-delivery buttons (for publisher on InReview) -- Shows select-winners form (for publisher on Competitive InReview) -- Shows mark-paid button (for publisher on Completed) -- Shows cancel button (for publisher on Open/Claimed) - -Uses Vue 3 Options API, Fomantic UI classes, `tw-` Tailwind prefix. - -- [ ] **Step 2: Update init.js to pass all required props** - -The P0 `init.js` only passes `bountyId`. Update to pass all data attributes: - -```javascript -export function initHackforger() { - const bountyEl = document.getElementById('hackforger-bounty-panel'); - if (bountyEl) { - (async () => { - const {default: BountyPanel} = await import( - /* webpackChunkName: "hackforger-bounty" */ - '../../components/hackforger/BountyPanel.vue' - ); - const {createApp} = await import('vue'); - createApp(BountyPanel, { - bountyId: bountyEl.getAttribute('data-bounty-id'), - repoOwner: bountyEl.getAttribute('data-repo-owner'), - repoName: bountyEl.getAttribute('data-repo-name'), - status: Number(bountyEl.getAttribute('data-status')), - mode: Number(bountyEl.getAttribute('data-mode')), - isPublisher: bountyEl.getAttribute('data-is-publisher') === 'true', - claimerId: Number(bountyEl.getAttribute('data-claimer-id')), - }).mount(bountyEl); - })(); - } -} -``` - -- [ ] **Step 3: Build frontend** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-bounty && make frontend` - -Expected: no errors - -- [ ] **Step 4: Commit** - -```bash -git add web_src/js/components/hackforger/BountyPanel.vue web_src/js/features/hackforger/init.js -git commit -m "feat(bounty): implement interactive BountyPanel Vue component - -Handles: apply, accept/reject applications, complete, reject delivery, -select winners, mark paid, cancel. Uses Options API + Fomantic UI." -``` - ---- - -## Chunk 5: Testing + E2E - -### Task 13: Run Full Test Suite - -- [ ] **Step 1: Run all hackforger model tests** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-bounty && go test ./models/hackforger/ -v -count=1 -tags "bindata sqlite sqlite_unlock_notify"` - -Expected: ALL PASS - -- [ ] **Step 2: Run all hackforger service tests** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-bounty && go test ./services/hackforger/ -v -count=1 -tags "bindata sqlite sqlite_unlock_notify"` - -Expected: ALL PASS - -- [ ] **Step 3: Run full backend build** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-bounty && TAGS="bindata sqlite sqlite_unlock_notify" make backend` - -Expected: no errors - -- [ ] **Step 4: Run frontend build** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-bounty && make frontend` - -Expected: no errors - ---- - -### Task 14: Create Manual E2E Test Prompt - -**Files:** -- Create: `docs/tests/e2e/p1-bounty-e2e-prompt.md` -- Create: `docs/tests/e2e/p1-bounty-e2e-report.md` - -- [ ] **Step 1: Write E2E prompt** - -Following P0 format, create step-by-step manual test guide covering: - -1. **Prerequisites**: P0 infrastructure verified, users/orgs/repos from P0 E2E exist -2. **Exclusive Bounty Flow**: Create bounty on `acme-dev/backend` issue → add rewards → apply (eve + agent_hunter) → accept eve → create PR + merge → verify InReview → complete → verify credits → mark paid → verify entity feed lifecycle -3. **Competitive Bounty Flow**: Create competitive bounty → multiple entries → select winners → verify ranked credits -4. **Feed Verification Checkpoints**: At each step, verify feed events appear for correct audiences -5. **Explore Page**: Verify `/explore/bounties` shows created bounties with filters -6. **Issue Panel**: Verify Bounty panel appears in Issue sidebar with correct status -7. **Issue Badge**: Verify Bounty badge appears in Issue list - -- [ ] **Step 2: Write empty report template** - -Following P0 format, create report template with checklist sections matching the prompt. - -- [ ] **Step 3: Commit** - -```bash -git add docs/tests/e2e/p1-bounty-e2e-prompt.md docs/tests/e2e/p1-bounty-e2e-report.md -git commit -m "docs: add Phase 1 Bounty manual E2E test prompt and report template" -``` - ---- - -### Task 15: Add Missing Service Functions (Leaderboard, Stats) - -**Files:** -- Modify: `services/hackforger/bounty.go` - -- [ ] **Step 1: Implement GetBountyLeaderboard** - -Append to `services/hackforger/bounty.go`: - -```go -// HunterStats holds aggregated bounty stats for a user. -type HunterStats struct { - UserID int64 `json:"user_id"` - BountiesCompleted int `json:"bounties_completed"` - TotalCredits int64 `json:"total_credits"` -} - -// GetBountyLeaderboard returns hunters ranked by completed bounties. -func GetBountyLeaderboard(ctx context.Context, limit int) ([]*HunterStats, error) { - var stats []*HunterStats - err := db.GetEngine(ctx).SQL( - "SELECT claimer_id AS user_id, COUNT(*) AS bounties_completed "+ - "FROM bounty WHERE status IN (?, ?) AND claimer_id > 0 "+ - "GROUP BY claimer_id ORDER BY bounties_completed DESC LIMIT ?", - hackforger_model.BountyStatusCompleted, hackforger_model.BountyStatusPaid, limit, - ).Find(&stats) - return stats, err -} - -// BountyStatsResult holds platform-wide bounty statistics. -type BountyStatsResult struct { - Total int64 `json:"total"` - Open int64 `json:"open"` - Completed int64 `json:"completed"` - Paid int64 `json:"paid"` -} - -// GetBountyStats returns platform-wide bounty statistics. -func GetBountyStats(ctx context.Context) (*BountyStatsResult, error) { - total, err := db.GetEngine(ctx).Count(new(hackforger_model.Bounty)) - if err != nil { - return nil, err - } - open, err := db.GetEngine(ctx).Where("status = ?", hackforger_model.BountyStatusOpen).Count(new(hackforger_model.Bounty)) - if err != nil { - return nil, err - } - completed, err := db.GetEngine(ctx).Where("status = ?", hackforger_model.BountyStatusCompleted).Count(new(hackforger_model.Bounty)) - if err != nil { - return nil, err - } - paid, err := db.GetEngine(ctx).Where("status = ?", hackforger_model.BountyStatusPaid).Count(new(hackforger_model.Bounty)) - if err != nil { - return nil, err - } - return &BountyStatsResult{Total: total, Open: open, Completed: completed, Paid: paid}, nil -} -``` - -- [ ] **Step 2: Add test for leaderboard** - -Append to `services/hackforger/bounty_test.go`: - -```go -func TestGetBountyLeaderboard(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Complete a bounty first so leaderboard has data - b, _ := hackforger_model.GetBountyByID(db.DefaultContext, 1) - b.Status = hackforger_model.BountyStatusCompleted - b.ClaimerID = 4 - require.NoError(t, hackforger_model.UpdateBounty(db.DefaultContext, b)) - - stats, err := GetBountyLeaderboard(db.DefaultContext, 10) - require.NoError(t, err) - assert.NotEmpty(t, stats) - assert.Equal(t, int64(4), stats[0].UserID) -} -``` - -- [ ] **Step 3: Run test, commit** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-bounty && go test ./services/hackforger/ -run TestGetBountyLeaderboard -v -count=1 -tags "bindata sqlite sqlite_unlock_notify"` - -```bash -git add services/hackforger/bounty.go services/hackforger/bounty_test.go -git commit -m "feat(bounty): add GetBountyLeaderboard and GetBountyStats functions" -``` - ---- - -### Task 16: Add Missing Service Tests - -**Files:** -- Modify: `services/hackforger/bounty_test.go` - -- [ ] **Step 1: Add comprehensive flow tests** - -Append to `services/hackforger/bounty_test.go`: - -```go -func TestExclusiveBountyFlow_Happy(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Apply - require.NoError(t, ApplyForBounty(db.DefaultContext, 1, 5, "I can do it")) - - // Accept → Claimed - pending := hackforger_model.ApplicationStatusPending - apps, _, _ := hackforger_model.ListBountyApplications(db.DefaultContext, hackforger_model.ListBountyApplicationsOptions{ - BountyID: 1, UserID: 5, Status: &pending, - }) - require.Len(t, apps, 1) - require.NoError(t, AcceptApplication(db.DefaultContext, apps[0].ID, 2)) - - b, _ := hackforger_model.GetBountyByID(db.DefaultContext, 1) - assert.Equal(t, hackforger_model.BountyStatusClaimed, b.Status) - assert.Equal(t, int64(5), b.ClaimerID) - - // Simulate PR merge → InReview - b.Status = hackforger_model.BountyStatusInReview - require.NoError(t, hackforger_model.UpdateBounty(db.DefaultContext, b)) - - // Complete → credits deposited - require.NoError(t, CompleteBounty(db.DefaultContext, 1, 2)) - b, _ = hackforger_model.GetBountyByID(db.DefaultContext, 1) - assert.Equal(t, hackforger_model.BountyStatusCompleted, b.Status) - - // Mark paid - require.NoError(t, MarkPaid(db.DefaultContext, 1, 2)) - b, _ = hackforger_model.GetBountyByID(db.DefaultContext, 1) - assert.Equal(t, hackforger_model.BountyStatusPaid, b.Status) -} - -func TestCompetitiveBountyFlow_Happy(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Bounty 2 is competitive + open - require.NoError(t, ApplyForBounty(db.DefaultContext, 2, 4, "entry 1")) - require.NoError(t, ApplyForBounty(db.DefaultContext, 2, 5, "entry 2")) - - // Start review - require.NoError(t, StartReview(db.DefaultContext, 2, 2)) - b, _ := hackforger_model.GetBountyByID(db.DefaultContext, 2) - assert.Equal(t, hackforger_model.BountyStatusInReview, b.Status) - - // Select winners - require.NoError(t, SelectWinners(db.DefaultContext, 2, 2, []WinnerInput{ - {UserID: 4, Rank: 1}, - {UserID: 5, Rank: 2}, - })) - b, _ = hackforger_model.GetBountyByID(db.DefaultContext, 2) - assert.Equal(t, hackforger_model.BountyStatusCompleted, b.Status) - - winners, _ := hackforger_model.ListBountyWinners(db.DefaultContext, 2) - assert.Len(t, winners, 2) -} - -func TestBountyApplication_AcceptRejectsOthers(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Two applications for bounty 1 - require.NoError(t, ApplyForBounty(db.DefaultContext, 1, 5, "me")) - require.NoError(t, ApplyForBounty(db.DefaultContext, 1, 6, "me too")) - - // Accept user 5's application - pending := hackforger_model.ApplicationStatusPending - apps, _, _ := hackforger_model.ListBountyApplications(db.DefaultContext, hackforger_model.ListBountyApplicationsOptions{ - BountyID: 1, UserID: 5, Status: &pending, - }) - require.NoError(t, AcceptApplication(db.DefaultContext, apps[0].ID, 2)) - - // User 4's original fixture application + user 6 should be rejected - rejected := hackforger_model.ApplicationStatusRejected - rejectedApps, count, _ := hackforger_model.ListBountyApplications(db.DefaultContext, hackforger_model.ListBountyApplicationsOptions{ - BountyID: 1, Status: &rejected, - }) - assert.Equal(t, int64(2), count) - _ = rejectedApps -} - -func TestCheckExpiredBounties(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Set bounty 1 deadline to past - b, _ := hackforger_model.GetBountyByID(db.DefaultContext, 1) - b.Deadline = 1 // Unix epoch + 1 second (far in the past) - require.NoError(t, hackforger_model.UpdateBounty(db.DefaultContext, b)) - - require.NoError(t, CheckExpiredBounties(db.DefaultContext)) - - b, _ = hackforger_model.GetBountyByID(db.DefaultContext, 1) - assert.Equal(t, hackforger_model.BountyStatusExpired, b.Status) -} - -func TestCompleteBounty_NoCreditsReward(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Create a bounty with only money reward (no credits) - bounty := &hackforger_model.Bounty{ - RepoID: 1, IssueID: 200, PublisherID: 2, ClaimerID: 4, - Title: "money only", Status: hackforger_model.BountyStatusInReview, - Mode: hackforger_model.BountyModeExclusive, - } - require.NoError(t, hackforger_model.CreateBounty(db.DefaultContext, bounty)) - require.NoError(t, hackforger_model.CreateBountyReward(db.DefaultContext, &hackforger_model.BountyReward{ - BountyID: bounty.ID, Type: hackforger_model.RewardTypeMoney, Amount: 100, Currency: "USD", - })) - - // Get initial credit balance - acct, _ := GetOrCreateCreditAccount(db.DefaultContext, 4) - initialBalance := acct.Balance - - require.NoError(t, CompleteBounty(db.DefaultContext, bounty.ID, 2)) - - acct, _ = GetOrCreateCreditAccount(db.DefaultContext, 4) - assert.Equal(t, initialBalance, acct.Balance, "balance should not change with money-only reward") -} -``` - -- [ ] **Step 2: Run all service tests** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-bounty && go test ./services/hackforger/ -v -count=1 -tags "bindata sqlite sqlite_unlock_notify"` - -Expected: ALL PASS - -- [ ] **Step 3: Commit** - -```bash -git add services/hackforger/bounty_test.go -git commit -m "test(bounty): add comprehensive flow tests per test plan - -Covers: ExclusiveBountyFlow_Happy, CompetitiveBountyFlow_Happy, -AcceptRejectsOthers, CheckExpiredBounties, NoCreditsReward." -``` - ---- - -### Task 17: Add NewBounty Web Handler and Template - -**Files:** -- Modify: `routers/web/hackforger/bounty.go` -- Create: `templates/hackforger/bounty/new.tmpl` -- Modify: `routers/web/web.go` - -- [ ] **Step 1: Add NewBounty and NewBountyPost handlers** - -Append to `routers/web/hackforger/bounty.go`: - -```go -// NewBounty renders the create bounty form. -func NewBounty(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("hackforger.bounty.new.title") - ctx.HTML(http.StatusOK, tplBountyNew) -} - -// NewBountyPost handles the create bounty form submission. -func NewBountyPost(ctx *context.Context) { - issueID := ctx.FormInt64("issue_id") - if issueID <= 0 { - ctx.Flash.Error("Issue is required") - ctx.Redirect(ctx.Repo.RepoLink + "/bounties/new") - return - } - - mode := hackforger_model.BountyMode(ctx.FormInt("mode")) - deadline := timeutil.TimeStamp(ctx.FormInt64("deadline")) - - bounty := &hackforger_model.Bounty{ - RepoID: ctx.Repo.Repository.ID, - IssueID: issueID, - PublisherID: ctx.Doer.ID, - Title: ctx.FormString("title"), - Mode: mode, - Deadline: deadline, - } - - if err := hackforger_model.CreateBounty(ctx, bounty); err != nil { - if hackforger_model.IsErrBountyAlreadyExists(err) { - ctx.Flash.Error("A bounty already exists for this issue") - ctx.Redirect(ctx.Repo.RepoLink + "/bounties/new") - return - } - ctx.ServerError("CreateBounty", err) - return - } - - // Publish feed event - _ = hackforger_svc.PublishHackforgerAction(ctx, &hackforger_svc.HackforgerActionOpts{ - ActUserID: ctx.Doer.ID, - OpType: hackforger_model.ActionBountyCreated, - RepoID: bounty.RepoID, - Content: &hackforger_model.HackforgerActionContent{EntityType: "bounty", EntityID: bounty.ID, EntityName: bounty.Title}, - AudienceType: hackforger_svc.AudienceGlobal | hackforger_svc.AudienceRepoWatchers, - }) - - ctx.Flash.Success("Bounty created successfully") - ctx.Redirect(fmt.Sprintf("%s/issues/%d", ctx.Repo.RepoLink, bounty.IssueID)) -} -``` - -- [ ] **Step 2: Create new.tmpl** - -Create `templates/hackforger/bounty/new.tmpl` with a form containing: issue selector dropdown, mode radio buttons (Exclusive/Competitive), title input, deadline date picker, submit button. Uses Fomantic UI form classes and i18n keys. - -- [ ] **Step 3: Register web routes** - -In `routers/web/web.go`, add inside the repo group: - -```go -// HackForger Bounty web routes -m.Group("/bounties", func() { - m.Combo("/new").Get(hackforger_web.NewBounty). - Post(web.Bind(forms.CreateIssueForm{}), hackforger_web.NewBountyPost) -}, reqSignIn, context.RepoMustNotBeArchived(), reqRepoIssueWriter) -``` - -- [ ] **Step 4: Verify compilation, commit** - -```bash -git add routers/web/hackforger/bounty.go templates/hackforger/bounty/new.tmpl routers/web/web.go -git commit -m "feat(bounty): add NewBounty web form for creating bounties from repo UI" -``` - ---- - -## Summary - -| Chunk | Tasks | Key Deliverables | -|-------|-------|-----------------| -| 1: Model | Tasks 1-6 | Fixtures, BountyStatus 7-state, BountyMode 2-mode, migration, CRUD for Application/Reward/Winner, ActionBountyPaid | -| 2: Service | Tasks 7-8, 15-16 | Notifier bitmask rewrite (shared infra), MergePullRequest hook, Bounty state machine (14 functions), Leaderboard, Stats, comprehensive flow tests | -| 3: API | Task 9 | 20 REST endpoints with Swagger annotations | -| 4: Web+Frontend | Tasks 10-12, 17 | Explore page, NewBounty form, Issue panel injection, Issue badge injection, BountyPanel.vue | -| 5: Testing+E2E | Tasks 13-14 | Full test suite run, manual E2E prompt/report | diff --git a/docs/superpowers/plans/2026-03-28-phase1-grant.md b/docs/superpowers/plans/2026-03-28-phase1-grant.md deleted file mode 100644 index c5296b1e39..0000000000 --- a/docs/superpowers/plans/2026-03-28-phase1-grant.md +++ /dev/null @@ -1,2579 +0,0 @@ -# Phase 1 Grant + Credits Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Implement Grant Round full-stack and Credits full-stack (Line C of HackForger parallel development). - -**Architecture:** Model-first bottom-up. All new code in `*/hackforger/` directories. Grant service owns state machine + budget validation. Credits service handles transactional deposit/redeem. Feed events via `PublishHackforgerAction` calls from service layer. SSR-first web pages. - -**Tech Stack:** Go 1.23, XORM ORM, go-chi router, Go HTML templates, Fomantic UI CSS, SQLite (dev). - -**Spec:** `docs/superpowers/specs/2026-03-28-phase1-grant-design.md` - ---- - -## File Map - -### Files to Modify (existing P0 code) - -| File | Change | -|------|--------| -| `models/hackforger/grant_round.go` | Fix status enum (6 states), add GetBySlug/Delete/BudgetUsage | -| `models/hackforger/grant_project.go` | Add GetByUserAndRound/Delete/Count, new error types | -| `models/hackforger/credit_account.go` | Add GetCreditAccount/ListCreditAccounts | -| `models/hackforger/credit_transaction.go` | Add ListAllTransactions | -| `models/hackforger/redeem_option.go` | Add Create/Update/GetByID | -| `models/hackforger/redeem_order.go` | Add GetByID/Update | -| `models/hackforger/action_types.go` | Add ActionGrantRoundCancelled = 57 | -| `services/hackforger/credits.go` | Append AdminDeposit/AdminDeduct/FulfillOrder/CancelOrder/CreateRedeemOption/UpdateRedeemOption | -| `routers/api/v1/hackforger/grants.go` | Replace stub with full CRUD + state operations | -| `routers/api/v1/hackforger/credits.go` | Replace stub with full API | -| `routers/api/v1/api.go:1739-1746` | Expand `/hackforger` group with sub-groups | -| `routers/web/web.go:504-506` | Keep explore route, add `/grants`, `/credits`, `/-/admin/credits` groups | -| `routers/web/hackforger/hackathon.go` | Remove ExploreGrants (moved to grants.go) | -| `templates/hackforger/explore.tmpl` | Add grants tab content | -| `options/locale/locale_en-US.ini` | Append grant + credits i18n keys | - -### Files to Create - -| File | Responsibility | -|------|---------------| -| `services/hackforger/grants.go` | Grant state machine, permission checks, all business logic | -| `routers/web/hackforger/grants.go` | Web handlers for grant pages | -| `routers/web/hackforger/credits.go` | Web handlers for credits pages | -| `templates/hackforger/grants/explore.tmpl` | Grant list (for explore page) | -| `templates/hackforger/grants/new.tmpl` | Create grant round form | -| `templates/hackforger/grants/detail.tmpl` | Round detail page | -| `templates/hackforger/grants/projects.tmpl` | Public project list | -| `templates/hackforger/grants/submit.tmpl` | Submit project form | -| `templates/hackforger/grants/manage.tmpl` | Management panel | -| `templates/hackforger/grants/manage_project.tmpl` | Single project review | -| `templates/hackforger/credits/overview.tmpl` | Balance + transactions + redeem | -| `templates/hackforger/credits/redeem.tmpl` | Redeem confirmation | -| `templates/hackforger/credits/orders.tmpl` | User order list | -| `templates/hackforger/credits/admin/credits.tmpl` | Admin credits management | -| `templates/hackforger/credits/admin/options.tmpl` | Admin redeem options | -| `templates/hackforger/credits/admin/orders.tmpl` | Admin orders | -| `models/hackforger/grant_round_test.go` | Grant round model tests | -| `models/hackforger/grant_project_test.go` | Grant project model tests | -| `models/hackforger/credits_test.go` | Credits model tests | -| `services/hackforger/grants_test.go` | Grant service tests | -| `services/hackforger/credits_test.go` | Credits service tests | -| `models/fixtures/grant_round.yml` | Test fixtures | -| `models/fixtures/grant_project.yml` | Test fixtures | -| `models/fixtures/credit_account.yml` | Test fixtures | -| `models/fixtures/credit_transaction.yml` | Test fixtures | -| `models/fixtures/redeem_option.yml` | Test fixtures | -| `models/fixtures/redeem_order.yml` | Test fixtures | -| `docs/tests/e2e/phase1-grant-e2e.md` | Manual E2E prompt + report template | - ---- - -## Chunk 1: Model Layer — Grant Round - -### Task 1: Fix GrantRound Status Enum - -**Files:** -- Modify: `models/hackforger/grant_round.go:18-26` - -- [ ] **Step 1: Update status enum** - -In `models/hackforger/grant_round.go`, replace the existing status constants: - -```go -// GrantRoundStatus represents the status of a grant round. -type GrantRoundStatus int - -const ( - GrantRoundStatusDraft GrantRoundStatus = iota // 0 - GrantRoundStatusOpen // 1 - GrantRoundStatusReview // 2 - GrantRoundStatusFinalized // 3 - renamed from Complete - GrantRoundStatusDistributed // 4 - new - GrantRoundStatusCancelled // 5 - re-numbered -) - -// GrantRoundStatusNames maps status to display name. -var GrantRoundStatusNames = map[GrantRoundStatus]string{ - GrantRoundStatusDraft: "draft", - GrantRoundStatusOpen: "open", - GrantRoundStatusReview: "review", - GrantRoundStatusFinalized: "finalized", - GrantRoundStatusDistributed: "distributed", - GrantRoundStatusCancelled: "cancelled", -} -``` - -- [ ] **Step 2: Verify compilation** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-grant && go build ./models/hackforger/...` -Expected: Success (no references to old `GrantRoundStatusComplete` exist in codebase) - -- [ ] **Step 3: Commit** - -```bash -git add models/hackforger/grant_round.go -git commit -m "refactor(grant): update GrantRound status enum to 6 states - -Rename Complete->Finalized, add Distributed, re-number Cancelled. -No migration needed: no production data exists yet." -``` - -### Task 2: Add GrantRound CRUD Functions + Error Types - -**Files:** -- Modify: `models/hackforger/grant_round.go` -- Create: `models/fixtures/grant_round.yml` -- Create: `models/hackforger/grant_round_test.go` - -- [ ] **Step 1: Create test fixtures** - -Create `models/fixtures/grant_round.yml`: - -```yaml -# grant_round.yml - Test fixture data -- - id: 1 - org_id: 3 # org from existing fixtures - owner_id: 2 # user 2 - name: "Test Grant Round Draft" - slug: "test-grant-draft" - description: "A draft grant round for testing" - status: 0 # Draft - budget: 10000.00 - currency: "USD" - budget_credits: 5000 - deadline: 0 - created_unix: 1609459200 - updated_unix: 1609459200 - -- - id: 2 - org_id: 3 - owner_id: 2 - name: "Test Grant Round Open" - slug: "test-grant-open" - description: "An open grant round for testing" - status: 1 # Open - budget: 20000.00 - currency: "USD" - budget_credits: 10000 - deadline: 1735689600 - created_unix: 1609459200 - updated_unix: 1609459200 - -- - id: 3 - org_id: 3 - owner_id: 2 - name: "Test Grant Round Review" - slug: "test-grant-review" - description: "A reviewing grant round" - status: 2 # Review - budget: 15000.00 - currency: "USD" - budget_credits: 8000 - deadline: 1735689600 - created_unix: 1609459200 - updated_unix: 1609459200 -``` - -- [ ] **Step 2: Write failing tests** - -Create `models/hackforger/grant_round_test.go`: - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package hackforger_test - -import ( - "testing" - - "forgejo.org/models/db" - hackforger_model "forgejo.org/models/hackforger" - "forgejo.org/models/unittest" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestMain(m *testing.M) { - unittest.MainTest(m) -} - -func TestCreateGrantRound(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - round := &hackforger_model.GrantRound{ - OrgID: 3, OwnerID: 2, Name: "New Round", Slug: "new-round", - Description: "test", Status: hackforger_model.GrantRoundStatusDraft, - Budget: 5000, Currency: "USD", BudgetCredits: 2000, - } - err := hackforger_model.CreateGrantRound(db.DefaultContext, round) - require.NoError(t, err) - assert.Greater(t, round.ID, int64(0)) - assert.Equal(t, hackforger_model.GrantRoundStatusDraft, round.Status) -} - -func TestGetGrantRoundBySlug(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - round, err := hackforger_model.GetGrantRoundBySlug(db.DefaultContext, "test-grant-draft") - require.NoError(t, err) - assert.Equal(t, int64(1), round.ID) - assert.Equal(t, "Test Grant Round Draft", round.Name) - assert.Equal(t, hackforger_model.GrantRoundStatusDraft, round.Status) - - // non-existent slug - _, err = hackforger_model.GetGrantRoundBySlug(db.DefaultContext, "non-existent") - assert.True(t, hackforger_model.IsErrGrantRoundNotExist(err)) -} - -func TestDeleteGrantRound_OnlyDraft(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Draft round can be deleted - err := hackforger_model.DeleteGrantRound(db.DefaultContext, 1) - require.NoError(t, err) - _, err = hackforger_model.GetGrantRoundByID(db.DefaultContext, 1) - assert.True(t, hackforger_model.IsErrGrantRoundNotExist(err)) - - // Open round cannot be deleted - err = hackforger_model.DeleteGrantRound(db.DefaultContext, 2) - assert.Error(t, err) - assert.True(t, hackforger_model.IsErrGrantRoundNotDraft(err)) -} - -func TestGetGrantRoundBudgetUsage(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Round 3 (Review) with no projects allocated yet - usedAmount, usedCredits, err := hackforger_model.GetGrantRoundBudgetUsage(db.DefaultContext, 3) - require.NoError(t, err) - assert.Equal(t, float64(0), usedAmount) - assert.Equal(t, int64(0), usedCredits) -} - -func TestListGrantRounds_FilterByStatus(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - status := hackforger_model.GrantRoundStatusOpen - rounds, count, err := hackforger_model.ListGrantRounds(db.DefaultContext, hackforger_model.ListGrantRoundsOptions{ - Status: &status, - }) - require.NoError(t, err) - assert.Equal(t, int64(1), count) - assert.Equal(t, hackforger_model.GrantRoundStatusOpen, rounds[0].Status) -} -``` - -- [ ] **Step 3: Run tests to verify they fail** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-grant && go test ./models/hackforger/... -run "TestGetGrantRoundBySlug|TestDeleteGrantRound|TestGetGrantRoundBudgetUsage|TestListGrantRounds_FilterByStatus" -v` -Expected: FAIL — `GetGrantRoundBySlug`, `DeleteGrantRound`, `GetGrantRoundBudgetUsage` undefined - -- [ ] **Step 4: Implement GrantRound CRUD additions** - -Append to `models/hackforger/grant_round.go`: - -```go -// ErrGrantRoundNotDraft is returned when trying to delete a non-draft round. -type ErrGrantRoundNotDraft struct { - ID int64 - Status GrantRoundStatus -} - -func IsErrGrantRoundNotDraft(err error) bool { - _, ok := err.(ErrGrantRoundNotDraft) - return ok -} - -func (err ErrGrantRoundNotDraft) Error() string { - return fmt.Sprintf("grant round is not in draft status [id: %d, status: %d]", err.ID, err.Status) -} - -func (err ErrGrantRoundNotDraft) Unwrap() error { - return util.ErrInvalidArgument -} - -// ErrGrantRoundSlugExists is returned when a slug already exists. -type ErrGrantRoundSlugExists struct { - Slug string -} - -func IsErrGrantRoundSlugExists(err error) bool { - _, ok := err.(ErrGrantRoundSlugExists) - return ok -} - -func (err ErrGrantRoundSlugExists) Error() string { - return fmt.Sprintf("grant round slug already exists [slug: %s]", err.Slug) -} - -func (err ErrGrantRoundSlugExists) Unwrap() error { - return util.ErrAlreadyExist -} - -// GetGrantRoundBySlug returns a grant round by its slug. -func GetGrantRoundBySlug(ctx context.Context, slug string) (*GrantRound, error) { - r := new(GrantRound) - has, err := db.GetEngine(ctx).Where("slug = ?", slug).Get(r) - if err != nil { - return nil, err - } - if !has { - return nil, ErrGrantRoundNotExist{ID: 0} - } - return r, nil -} - -// DeleteGrantRound deletes a grant round. Only Draft rounds can be deleted. -func DeleteGrantRound(ctx context.Context, id int64) error { - r, err := GetGrantRoundByID(ctx, id) - if err != nil { - return err - } - if r.Status != GrantRoundStatusDraft { - return ErrGrantRoundNotDraft{ID: id, Status: r.Status} - } - _, err = db.GetEngine(ctx).ID(id).Delete(new(GrantRound)) - return err -} - -// GetGrantRoundBudgetUsage returns the sum of allocated amounts and credits -// for approved/funded projects in a round. -func GetGrantRoundBudgetUsage(ctx context.Context, roundID int64) (float64, int64, error) { - type result struct { - SumAmount float64 `xorm:"sum_amount"` - SumCredits int64 `xorm:"sum_credits"` - } - var res result - _, err := db.GetEngine(ctx). - Table("grant_project"). - Select("COALESCE(SUM(award_amount), 0) AS sum_amount, COALESCE(SUM(award_credits), 0) AS sum_credits"). - Where("round_id = ? AND status IN (?, ?)", roundID, GrantProjectStatusApproved, GrantProjectStatusFunded). - Get(&res) - if err != nil { - return 0, 0, err - } - return res.SumAmount, res.SumCredits, nil -} -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-grant && go test ./models/hackforger/... -run "TestGetGrantRoundBySlug|TestDeleteGrantRound|TestGetGrantRoundBudgetUsage|TestListGrantRounds_FilterByStatus" -v` -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add models/hackforger/grant_round.go models/hackforger/grant_round_test.go models/fixtures/grant_round.yml -git commit -m "feat(grant): add GrantRound GetBySlug, Delete, BudgetUsage + tests" -``` - ---- - -## Chunk 2: Model Layer — Grant Project + Credits - -### Task 3: Add GrantProject CRUD + Error Types - -**Files:** -- Modify: `models/hackforger/grant_project.go` -- Create: `models/fixtures/grant_project.yml` -- Create: `models/hackforger/grant_project_test.go` - -- [ ] **Step 1: Create test fixtures** - -Create `models/fixtures/grant_project.yml`: - -```yaml -- - id: 1 - round_id: 2 # Open round - user_id: 4 # hacker user - repo_id: 1 - title: "Eve's Project" - description: "A test project submission" - status: 0 # Pending - award_amount: 0 - award_credits: 0 - created_unix: 1609459200 - updated_unix: 1609459200 - -- - id: 2 - round_id: 3 # Review round - user_id: 4 - repo_id: 1 - title: "Eve's Approved Project" - description: "An approved project" - status: 1 # Approved - award_amount: 5000.00 - award_credits: 2000 - created_unix: 1609459200 - updated_unix: 1609459200 - -- - id: 3 - round_id: 3 # Review round - user_id: 5 # different user - repo_id: 2 - title: "Frank's Approved Project" - description: "Another approved project" - status: 1 # Approved - award_amount: 3000.00 - award_credits: 1500 - created_unix: 1609459200 - updated_unix: 1609459200 -``` - -- [ ] **Step 2: Write failing tests** - -Create `models/hackforger/grant_project_test.go`: - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package hackforger_test - -import ( - "testing" - - "forgejo.org/models/db" - hackforger_model "forgejo.org/models/hackforger" - "forgejo.org/models/unittest" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGetGrantProjectByUserAndRound(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Existing project - p, err := hackforger_model.GetGrantProjectByUserAndRound(db.DefaultContext, 4, 2) - require.NoError(t, err) - assert.Equal(t, int64(1), p.ID) - - // Non-existent - _, err = hackforger_model.GetGrantProjectByUserAndRound(db.DefaultContext, 999, 2) - assert.True(t, hackforger_model.IsErrGrantProjectNotExist(err)) -} - -func TestDeleteGrantProject_OnlyPending(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Pending project can be deleted - err := hackforger_model.DeleteGrantProject(db.DefaultContext, 1) - require.NoError(t, err) - - // Approved project cannot be deleted - err = hackforger_model.DeleteGrantProject(db.DefaultContext, 2) - assert.Error(t, err) -} - -func TestCountGrantProjectsByRound(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - status := hackforger_model.GrantProjectStatusApproved - count, err := hackforger_model.CountGrantProjectsByRound(db.DefaultContext, 3, &status) - require.NoError(t, err) - assert.Equal(t, int64(2), count) // projects 2 and 3 -} - -func TestListGrantProjectsByRound_FilterByStatus(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - status := hackforger_model.GrantProjectStatusPending - projects, count, err := hackforger_model.ListGrantProjectsByRound(db.DefaultContext, - hackforger_model.ListGrantProjectsByRoundOptions{ - RoundID: 2, - Status: &status, - }) - require.NoError(t, err) - assert.Equal(t, int64(1), count) - assert.Equal(t, "Eve's Project", projects[0].Title) -} -``` - -- [ ] **Step 3: Run tests to verify they fail** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-grant && go test ./models/hackforger/... -run "TestGetGrantProjectByUserAndRound|TestDeleteGrantProject|TestCountGrantProjectsByRound|TestListGrantProjectsByRound_FilterByStatus" -v` -Expected: FAIL - -- [ ] **Step 4: Implement GrantProject additions** - -Append to `models/hackforger/grant_project.go`: - -```go -// Note: GrantProjectStatusFunded (3) is already defined in P0 code. Do NOT re-add it. - -// ErrGrantProjectAlreadyExists is returned when a user already submitted to a round. -type ErrGrantProjectAlreadyExists struct { - UserID int64 - RoundID int64 -} - -func IsErrGrantProjectAlreadyExists(err error) bool { - _, ok := err.(ErrGrantProjectAlreadyExists) - return ok -} - -func (err ErrGrantProjectAlreadyExists) Error() string { - return fmt.Sprintf("grant project already exists [user_id: %d, round_id: %d]", err.UserID, err.RoundID) -} - -func (err ErrGrantProjectAlreadyExists) Unwrap() error { - return util.ErrAlreadyExist -} - -// ErrGrantRoundNotOpen is returned when submitting to a non-open round. -type ErrGrantRoundNotOpen struct { - RoundID int64 - Status GrantRoundStatus -} - -func IsErrGrantRoundNotOpen(err error) bool { - _, ok := err.(ErrGrantRoundNotOpen) - return ok -} - -func (err ErrGrantRoundNotOpen) Error() string { - return fmt.Sprintf("grant round is not open [round_id: %d, status: %d]", err.RoundID, err.Status) -} - -func (err ErrGrantRoundNotOpen) Unwrap() error { - return util.ErrInvalidArgument -} - -// ErrExceedsBudget is returned when an allocation would exceed the round budget. -type ErrExceedsBudget struct { - RoundID int64 - BudgetField string - Budget float64 - Used float64 - Requested float64 -} - -func IsErrExceedsBudget(err error) bool { - _, ok := err.(ErrExceedsBudget) - return ok -} - -func (err ErrExceedsBudget) Error() string { - return fmt.Sprintf("allocation exceeds budget [round_id: %d, field: %s, budget: %.2f, used: %.2f, requested: %.2f]", - err.RoundID, err.BudgetField, err.Budget, err.Used, err.Requested) -} - -func (err ErrExceedsBudget) Unwrap() error { - return util.ErrInvalidArgument -} - -// ErrUnallocatedProjects is returned when finalizing a round with unallocated approved projects. -type ErrUnallocatedProjects struct { - RoundID int64 - Count int64 -} - -func IsErrUnallocatedProjects(err error) bool { - _, ok := err.(ErrUnallocatedProjects) - return ok -} - -func (err ErrUnallocatedProjects) Error() string { - return fmt.Sprintf("round has unallocated approved projects [round_id: %d, count: %d]", err.RoundID, err.Count) -} - -func (err ErrUnallocatedProjects) Unwrap() error { - return util.ErrInvalidArgument -} - -// GetGrantProjectByUserAndRound returns a project for a specific user in a round. -func GetGrantProjectByUserAndRound(ctx context.Context, userID, roundID int64) (*GrantProject, error) { - p := new(GrantProject) - has, err := db.GetEngine(ctx).Where("user_id = ? AND round_id = ?", userID, roundID).Get(p) - if err != nil { - return nil, err - } - if !has { - return nil, ErrGrantProjectNotExist{ID: 0} - } - return p, nil -} - -// DeleteGrantProject deletes a project. Only Pending projects can be deleted. -func DeleteGrantProject(ctx context.Context, id int64) error { - p, err := GetGrantProjectByID(ctx, id) - if err != nil { - return err - } - if p.Status != GrantProjectStatusPending { - return fmt.Errorf("cannot delete grant project with status %d", p.Status) - } - _, err = db.GetEngine(ctx).ID(id).Delete(new(GrantProject)) - return err -} - -// CountGrantProjectsByRound counts projects by round and optional status filter. -func CountGrantProjectsByRound(ctx context.Context, roundID int64, status *GrantProjectStatus) (int64, error) { - sess := db.GetEngine(ctx).Where("round_id = ?", roundID) - if status != nil { - sess = sess.And("status = ?", *status) - } - return sess.Count(new(GrantProject)) -} -``` - -Note: `GrantProjectStatusFunded` (3) is already defined in P0 code at `grant_project.go:24`. Do NOT add it again. - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-grant && go test ./models/hackforger/... -run "TestGetGrantProjectByUserAndRound|TestDeleteGrantProject|TestCountGrantProjectsByRound|TestListGrantProjectsByRound_FilterByStatus" -v` -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add models/hackforger/grant_project.go models/hackforger/grant_project_test.go models/fixtures/grant_project.yml -git commit -m "feat(grant): add GrantProject GetByUserAndRound, Delete, Count + error types + tests" -``` - -### Task 4: Add Credits Model CRUD Functions - -**Files:** -- Modify: `models/hackforger/credit_account.go` -- Modify: `models/hackforger/credit_transaction.go` -- Modify: `models/hackforger/redeem_option.go` -- Modify: `models/hackforger/redeem_order.go` -- Create: `models/fixtures/credit_account.yml` -- Create: `models/fixtures/credit_transaction.yml` -- Create: `models/fixtures/redeem_option.yml` -- Create: `models/fixtures/redeem_order.yml` -- Create: `models/hackforger/credits_test.go` - -- [ ] **Step 1: Create test fixtures** - -Create `models/fixtures/credit_account.yml`: -```yaml -- - id: 1 - user_id: 4 - balance: 1000 - updated_unix: 1609459200 -``` - -Create `models/fixtures/credit_transaction.yml`: -```yaml -- - id: 1 - user_id: 4 - type: "deposit" - amount: 1000 - balance: 1000 - reference: "grant_award" - note: "Grant award for project" - created_unix: 1609459200 -``` - -Create `models/fixtures/redeem_option.yml`: -```yaml -- - id: 1 - name: "Cloud Credits $10" - description: "10 USD cloud computing credits" - cost: 500 - stock: 10 - is_active: 1 - created_unix: 1609459200 - updated_unix: 1609459200 - -- - id: 2 - name: "Unlimited License" - description: "Software license" - cost: 2000 - stock: -1 - is_active: 1 - created_unix: 1609459200 - updated_unix: 1609459200 -``` - -Create `models/fixtures/redeem_order.yml`: -```yaml -- - id: 1 - user_id: 4 - option_id: 1 - cost: 500 - status: "pending" - fulfill_note: "" - created_unix: 1609459200 - updated_unix: 1609459200 -``` - -- [ ] **Step 2: Write failing tests** - -Create `models/hackforger/credits_test.go`: - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package hackforger_test - -import ( - "testing" - - "forgejo.org/models/db" - hackforger_model "forgejo.org/models/hackforger" - "forgejo.org/models/unittest" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGetCreditAccount(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - acct, err := hackforger_model.GetCreditAccount(db.DefaultContext, 4) - require.NoError(t, err) - assert.Equal(t, int64(1000), acct.Balance) - - // Non-existent user returns nil, no error - acct, err = hackforger_model.GetCreditAccount(db.DefaultContext, 999) - require.NoError(t, err) - assert.Nil(t, acct) -} - -func TestCreateRedeemOption(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - opt := &hackforger_model.RedeemOption{ - Name: "New Option", - Description: "Test", - Cost: 100, - Stock: 5, - IsActive: true, - } - err := hackforger_model.CreateRedeemOption(db.DefaultContext, opt) - require.NoError(t, err) - assert.Greater(t, opt.ID, int64(0)) - - loaded, err := hackforger_model.GetRedeemOptionByID(db.DefaultContext, opt.ID) - require.NoError(t, err) - assert.Equal(t, "New Option", loaded.Name) -} - -func TestGetRedeemOrderByID(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - order, err := hackforger_model.GetRedeemOrderByID(db.DefaultContext, 1) - require.NoError(t, err) - assert.Equal(t, int64(4), order.UserID) - assert.Equal(t, hackforger_model.OrderStatusPending, order.Status) -} - -func TestUpdateRedeemOrder(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - order, err := hackforger_model.GetRedeemOrderByID(db.DefaultContext, 1) - require.NoError(t, err) - - order.Status = hackforger_model.OrderStatusFulfilled - order.FulfillNote = "Sent via email" - err = hackforger_model.UpdateRedeemOrder(db.DefaultContext, order) - require.NoError(t, err) - - updated, err := hackforger_model.GetRedeemOrderByID(db.DefaultContext, 1) - require.NoError(t, err) - assert.Equal(t, hackforger_model.OrderStatusFulfilled, updated.Status) - assert.Equal(t, "Sent via email", updated.FulfillNote) -} -``` - -- [ ] **Step 3: Run tests to verify they fail** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-grant && go test ./models/hackforger/... -run "TestGetCreditAccount|TestCreateRedeemOption|TestGetRedeemOrderByID|TestUpdateRedeemOrder" -v` -Expected: FAIL - -- [ ] **Step 4: Implement Credits CRUD** - -Append to `models/hackforger/credit_account.go`: - -```go -import ( - "context" - - "forgejo.org/models/db" - - "xorm.io/builder" -) - -// GetCreditAccount returns the credit account for a user, or nil if not found. -func GetCreditAccount(ctx context.Context, userID int64) (*CreditAccount, error) { - acct := new(CreditAccount) - has, err := db.GetEngine(ctx).Where("user_id = ?", userID).Get(acct) - if err != nil { - return nil, err - } - if !has { - return nil, nil - } - return acct, nil -} - -// ListCreditAccountsOptions holds options for listing credit accounts. -type ListCreditAccountsOptions struct { - db.ListOptions -} - -func (opts ListCreditAccountsOptions) ToConds() builder.Cond { - return builder.NewCond() -} - -// ListCreditAccounts returns all credit accounts (admin). -func ListCreditAccounts(ctx context.Context, opts ListCreditAccountsOptions) ([]*CreditAccount, int64, error) { - return db.FindAndCount[CreditAccount](ctx, opts) -} -``` - -Append to `models/hackforger/credit_transaction.go`: - -```go -import ( - "context" - - "forgejo.org/models/db" - - "xorm.io/builder" -) - -// ListAllTransactionsOptions holds options for listing all transactions. -type ListAllTransactionsOptions struct { - db.ListOptions - UserID int64 -} - -func (opts ListAllTransactionsOptions) ToConds() builder.Cond { - cond := builder.NewCond() - if opts.UserID > 0 { - cond = cond.And(builder.Eq{"user_id": opts.UserID}) - } - return cond -} - -// ListAllTransactions returns transactions (admin global view). -func ListAllTransactions(ctx context.Context, opts ListAllTransactionsOptions) ([]*CreditTransaction, int64, error) { - return db.FindAndCount[CreditTransaction](ctx, opts) -} -``` - -Append to `models/hackforger/redeem_option.go`: - -```go -import "context" - -// CreateRedeemOption inserts a new redeem option. -func CreateRedeemOption(ctx context.Context, opt *RedeemOption) error { - _, err := db.GetEngine(ctx).Insert(opt) - return err -} - -// ErrRedeemOptionNotExist represents a "RedeemOptionNotExist" kind of error. -type ErrRedeemOptionNotExist struct { - ID int64 -} - -func IsErrRedeemOptionNotExist(err error) bool { - _, ok := err.(ErrRedeemOptionNotExist) - return ok -} - -func (err ErrRedeemOptionNotExist) Error() string { - return fmt.Sprintf("redeem option does not exist [id: %d]", err.ID) -} - -func (err ErrRedeemOptionNotExist) Unwrap() error { - return util.ErrNotExist -} - -// GetRedeemOptionByID returns a redeem option by ID. -func GetRedeemOptionByID(ctx context.Context, id int64) (*RedeemOption, error) { - opt := new(RedeemOption) - has, err := db.GetEngine(ctx).ID(id).Get(opt) - if err != nil { - return nil, err - } - if !has { - return nil, ErrRedeemOptionNotExist{ID: id} - } - return opt, nil -} - -// UpdateRedeemOption updates a redeem option. -func UpdateRedeemOption(ctx context.Context, opt *RedeemOption) error { - _, err := db.GetEngine(ctx).ID(opt.ID).AllCols().Update(opt) - return err -} -``` - -Append to `models/hackforger/redeem_order.go`: - -```go -import "context" - -// ErrRedeemOrderNotExist represents a "RedeemOrderNotExist" kind of error. -type ErrRedeemOrderNotExist struct { - ID int64 -} - -func IsErrRedeemOrderNotExist(err error) bool { - _, ok := err.(ErrRedeemOrderNotExist) - return ok -} - -func (err ErrRedeemOrderNotExist) Error() string { - return fmt.Sprintf("redeem order does not exist [id: %d]", err.ID) -} - -func (err ErrRedeemOrderNotExist) Unwrap() error { - return util.ErrNotExist -} - -// GetRedeemOrderByID returns a redeem order by ID. -func GetRedeemOrderByID(ctx context.Context, id int64) (*RedeemOrder, error) { - order := new(RedeemOrder) - has, err := db.GetEngine(ctx).ID(id).Get(order) - if err != nil { - return nil, err - } - if !has { - return nil, ErrRedeemOrderNotExist{ID: id} - } - return order, nil -} - -// UpdateRedeemOrder updates a redeem order. -func UpdateRedeemOrder(ctx context.Context, order *RedeemOrder) error { - _, err := db.GetEngine(ctx).ID(order.ID).AllCols().Update(order) - return err -} -``` - -Note: Each file already has some imports. Merge the new imports with existing ones — don't duplicate. The `context` import may already exist; `xorm.io/builder` is new for some files. - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-grant && go test ./models/hackforger/... -run "TestGetCreditAccount|TestCreateRedeemOption|TestGetRedeemOrderByID|TestUpdateRedeemOrder" -v` -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add models/hackforger/credit_account.go models/hackforger/credit_transaction.go models/hackforger/redeem_option.go models/hackforger/redeem_order.go models/hackforger/credits_test.go models/fixtures/credit_account.yml models/fixtures/credit_transaction.yml models/fixtures/redeem_option.yml models/fixtures/redeem_order.yml -git commit -m "feat(credits): add CRUD functions for CreditAccount, CreditTransaction, RedeemOption, RedeemOrder + tests" -``` - -### Task 5: Add ActionGrantRoundCancelled to action_types.go - -**Files:** -- Modify: `models/hackforger/action_types.go:35-36` - -- [ ] **Step 1: Add new action type** - -In `models/hackforger/action_types.go`, after line 35 (`ActionGrantRoundFinalized`), add: - -```go -ActionGrantRoundCancelled activities_model.ActionType = 57 -``` - -And in the `HackforgerActionTypeName` map, add: - -```go -ActionGrantRoundCancelled: "grant_round_cancelled", -``` - -- [ ] **Step 2: Verify compilation** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-grant && go build ./models/hackforger/...` -Expected: Success - -- [ ] **Step 3: Commit** - -```bash -git add models/hackforger/action_types.go -git commit -m "feat(grant): add ActionGrantRoundCancelled (57) to action types" -``` - ---- - -## Chunk 3: Service Layer — Grants - -### Task 6: Implement Grant Service (State Machine + Business Logic) - -**Files:** -- Create: `services/hackforger/grants.go` -- Create: `services/hackforger/grants_test.go` - -- [ ] **Step 1: Write failing tests** - -Create `services/hackforger/grants_test.go`: - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package hackforger_test - -import ( - "testing" - - "forgejo.org/models/db" - hackforger_model "forgejo.org/models/hackforger" - "forgejo.org/models/unittest" - hackforger_service "forgejo.org/services/hackforger" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestMain(m *testing.M) { - unittest.MainTest(m) -} - -func TestGrantRoundStatusTransition_Valid(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Draft -> Open - err := hackforger_service.OpenRound(db.DefaultContext, 2, 1) // ownerID=2, roundID=1 (Draft) - require.NoError(t, err) - round, _ := hackforger_model.GetGrantRoundByID(db.DefaultContext, 1) - assert.Equal(t, hackforger_model.GrantRoundStatusOpen, round.Status) -} - -func TestGrantRoundStatusTransition_Invalid(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Draft -> Finalized (invalid, must go through Open -> Review first) - err := hackforger_service.FinalizeRound(db.DefaultContext, 2, 1) // roundID=1 is Draft - assert.Error(t, err) -} - -func TestSubmitProject_RoundNotOpen(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Round 1 is Draft, not Open - _, err := hackforger_service.SubmitProject(db.DefaultContext, 4, 1, hackforger_service.SubmitProjectOpts{ - Title: "Test", - Description: "Test project", - }) - assert.True(t, hackforger_model.IsErrGrantRoundNotOpen(err)) -} - -func TestSubmitProject_Duplicate(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // User 4 already has a project in round 2 (from fixtures) - _, err := hackforger_service.SubmitProject(db.DefaultContext, 4, 2, hackforger_service.SubmitProjectOpts{ - Title: "Duplicate", - Description: "Should fail", - }) - assert.True(t, hackforger_model.IsErrGrantProjectAlreadyExists(err)) -} - -func TestAllocateAward_ExceedsBudget(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Round 3 has budget 15000 USD / 8000 credits - // Projects 2 and 3 already have 5000+3000=8000 allocated - // Trying to add more than remaining 7000 should fail - err := hackforger_service.AllocateAward(db.DefaultContext, 2, 2, 8000.00, 0) // exceeds by 1000 - assert.True(t, hackforger_model.IsErrExceedsBudget(err)) -} - -func TestFinalizeRound_UnallocatedProjects(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Need a round in Review with an Approved project that has AwardAmount=0 - // Create this scenario in the test - round := &hackforger_model.GrantRound{ - OrgID: 3, OwnerID: 2, Name: "Finalize Test", Slug: "finalize-test", - Status: hackforger_model.GrantRoundStatusReview, Budget: 10000, Currency: "USD", BudgetCredits: 5000, - } - require.NoError(t, hackforger_model.CreateGrantRound(db.DefaultContext, round)) - - project := &hackforger_model.GrantProject{ - RoundID: round.ID, UserID: 4, Title: "Unallocated", - Status: hackforger_model.GrantProjectStatusApproved, - } - require.NoError(t, hackforger_model.CreateGrantProject(db.DefaultContext, project)) - - err := hackforger_service.FinalizeRound(db.DefaultContext, 2, round.ID) - assert.True(t, hackforger_model.IsErrUnallocatedProjects(err)) -} - -func TestDistributeProject_CreditsDeposit(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Setup: round in Finalized state with approved+allocated project - round := &hackforger_model.GrantRound{ - OrgID: 3, OwnerID: 2, Name: "Distribute Test", Slug: "distribute-test", - Status: hackforger_model.GrantRoundStatusFinalized, Budget: 10000, Currency: "USD", BudgetCredits: 5000, - } - require.NoError(t, hackforger_model.CreateGrantRound(db.DefaultContext, round)) - - project := &hackforger_model.GrantProject{ - RoundID: round.ID, UserID: 4, Title: "To Distribute", - Status: hackforger_model.GrantProjectStatusApproved, - AwardAmount: 1000, AwardCredits: 500, - } - require.NoError(t, hackforger_model.CreateGrantProject(db.DefaultContext, project)) - - // Get balance before - acctBefore, _ := hackforger_service.GetOrCreateCreditAccount(db.DefaultContext, 4) - balanceBefore := acctBefore.Balance - - // Distribute - err := hackforger_service.DistributeProject(db.DefaultContext, 2, project.ID) - require.NoError(t, err) - - // Verify project status -> Funded - updated, _ := hackforger_model.GetGrantProjectByID(db.DefaultContext, project.ID) - assert.Equal(t, hackforger_model.GrantProjectStatusFunded, updated.Status) - - // Verify credits deposited - acctAfter, _ := hackforger_service.GetOrCreateCreditAccount(db.DefaultContext, 4) - assert.Equal(t, balanceBefore+500, acctAfter.Balance) -} - -func TestDistributeProject_AutoDistributeRound(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Setup: Finalized round with ONE approved project - round := &hackforger_model.GrantRound{ - OrgID: 3, OwnerID: 2, Name: "Auto Distribute", Slug: "auto-distribute-test", - Status: hackforger_model.GrantRoundStatusFinalized, Budget: 5000, Currency: "USD", BudgetCredits: 2000, - } - require.NoError(t, hackforger_model.CreateGrantRound(db.DefaultContext, round)) - - project := &hackforger_model.GrantProject{ - RoundID: round.ID, UserID: 4, Title: "Only Project", - Status: hackforger_model.GrantProjectStatusApproved, - AwardAmount: 1000, AwardCredits: 500, - } - require.NoError(t, hackforger_model.CreateGrantProject(db.DefaultContext, project)) - - err := hackforger_service.DistributeProject(db.DefaultContext, 2, project.ID) - require.NoError(t, err) - - // Round should auto-transition to Distributed - updatedRound, _ := hackforger_model.GetGrantRoundByID(db.DefaultContext, round.ID) - assert.Equal(t, hackforger_model.GrantRoundStatusDistributed, updatedRound.Status) -} - -func TestCancelRound(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Open round can be cancelled - err := hackforger_service.CancelRound(db.DefaultContext, 2, 2) // roundID=2 is Open - require.NoError(t, err) - round, _ := hackforger_model.GetGrantRoundByID(db.DefaultContext, 2) - assert.Equal(t, hackforger_model.GrantRoundStatusCancelled, round.Status) -} - -func TestCancelRound_AlreadyDistributed_Rejected(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - round := &hackforger_model.GrantRound{ - OrgID: 3, OwnerID: 2, Name: "Distributed Round", Slug: "cancel-distributed-test", - Status: hackforger_model.GrantRoundStatusDistributed, Budget: 5000, Currency: "USD", - } - require.NoError(t, hackforger_model.CreateGrantRound(db.DefaultContext, round)) - - err := hackforger_service.CancelRound(db.DefaultContext, 2, round.ID) - assert.Error(t, err) -} - -func TestCancelRound_AlreadyCancelled_Rejected(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - round := &hackforger_model.GrantRound{ - OrgID: 3, OwnerID: 2, Name: "Cancelled Round", Slug: "cancel-cancelled-test", - Status: hackforger_model.GrantRoundStatusCancelled, Budget: 5000, Currency: "USD", - } - require.NoError(t, hackforger_model.CreateGrantRound(db.DefaultContext, round)) - - err := hackforger_service.CancelRound(db.DefaultContext, 2, round.ID) - assert.Error(t, err) -} - -func TestDeleteGrantRound_NonDraft_Rejected(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Round 2 is Open, should not be deletable via service - err := hackforger_service.DeleteGrantRoundService(db.DefaultContext, 2, 2) - assert.Error(t, err) -} - -func TestDistributeRound_BatchConvenience(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Setup: Finalized round with 2 approved+allocated projects - round := &hackforger_model.GrantRound{ - OrgID: 3, OwnerID: 2, Name: "Batch Test", Slug: "batch-distribute-test", - Status: hackforger_model.GrantRoundStatusFinalized, Budget: 10000, Currency: "USD", BudgetCredits: 5000, - } - require.NoError(t, hackforger_model.CreateGrantRound(db.DefaultContext, round)) - - p1 := &hackforger_model.GrantProject{ - RoundID: round.ID, UserID: 4, Title: "Batch P1", - Status: hackforger_model.GrantProjectStatusApproved, AwardCredits: 300, - } - p2 := &hackforger_model.GrantProject{ - RoundID: round.ID, UserID: 5, Title: "Batch P2", - Status: hackforger_model.GrantProjectStatusApproved, AwardCredits: 200, - } - require.NoError(t, hackforger_model.CreateGrantProject(db.DefaultContext, p1)) - require.NoError(t, hackforger_model.CreateGrantProject(db.DefaultContext, p2)) - - err := hackforger_service.DistributeRound(db.DefaultContext, 2, round.ID) - require.NoError(t, err) - - // Both projects should be Funded - up1, _ := hackforger_model.GetGrantProjectByID(db.DefaultContext, p1.ID) - up2, _ := hackforger_model.GetGrantProjectByID(db.DefaultContext, p2.ID) - assert.Equal(t, hackforger_model.GrantProjectStatusFunded, up1.Status) - assert.Equal(t, hackforger_model.GrantProjectStatusFunded, up2.Status) - - // Round should be Distributed - updatedRound, _ := hackforger_model.GetGrantRoundByID(db.DefaultContext, round.ID) - assert.Equal(t, hackforger_model.GrantRoundStatusDistributed, updatedRound.Status) -} - -func TestCreateGrantRound_Service(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - round, err := hackforger_service.CreateGrantRound(db.DefaultContext, 2, 3, hackforger_service.CreateGrantRoundOpts{ - Name: "New Round", Slug: "new-round-test", Description: "Test", - Budget: 5000, Currency: "USD", BudgetCredits: 2000, - }) - require.NoError(t, err) - assert.Greater(t, round.ID, int64(0)) - assert.Equal(t, hackforger_model.GrantRoundStatusDraft, round.Status) -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-grant && go test ./services/hackforger/... -run "TestGrantRound|TestSubmitProject|TestAllocateAward|TestFinalizeRound|TestDistributeProject|TestCancelRound" -v` -Expected: FAIL — `grants.go` does not exist yet - -- [ ] **Step 3: Implement Grant Service** - -Create `services/hackforger/grants.go`: - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package hackforger - -import ( - "bytes" - "context" - "encoding/csv" - "fmt" - - activities_model "forgejo.org/models/activities" - "forgejo.org/models/db" - hackforger_model "forgejo.org/models/hackforger" - org_model "forgejo.org/models/organization" - "forgejo.org/modules/log" - "forgejo.org/modules/timeutil" -) - -// SubmitProjectOpts holds options for submitting a project. -type SubmitProjectOpts struct { - Title string - Description string - RepoID int64 -} - -// checkGrantRoundAccess verifies the doer is an owner or admin of the round's org. -func checkGrantRoundAccess(ctx context.Context, doerID int64, round *hackforger_model.GrantRound) error { - isOwner, err := org_model.IsOrganizationOwner(ctx, round.OrgID, doerID) - if err != nil { - return err - } - if isOwner { - return nil - } - isAdmin, err := org_model.IsOrganizationAdmin(ctx, round.OrgID, doerID) - if err != nil { - return err - } - if isAdmin { - return nil - } - return fmt.Errorf("user %d is not an owner or admin of org %d", doerID, round.OrgID) -} - -// validTransitions maps current status to allowed next statuses. -var validTransitions = map[hackforger_model.GrantRoundStatus][]hackforger_model.GrantRoundStatus{ - hackforger_model.GrantRoundStatusDraft: {hackforger_model.GrantRoundStatusOpen, hackforger_model.GrantRoundStatusCancelled}, - hackforger_model.GrantRoundStatusOpen: {hackforger_model.GrantRoundStatusReview, hackforger_model.GrantRoundStatusCancelled}, - hackforger_model.GrantRoundStatusReview: {hackforger_model.GrantRoundStatusFinalized, hackforger_model.GrantRoundStatusCancelled}, - hackforger_model.GrantRoundStatusFinalized: {hackforger_model.GrantRoundStatusDistributed, hackforger_model.GrantRoundStatusCancelled}, -} - -func isValidTransition(from, to hackforger_model.GrantRoundStatus) bool { - allowed, ok := validTransitions[from] - if !ok { - return false - } - for _, s := range allowed { - if s == to { - return true - } - } - return false -} - -func transitionRound(ctx context.Context, doerID, roundID int64, target hackforger_model.GrantRoundStatus) (*hackforger_model.GrantRound, error) { - round, err := hackforger_model.GetGrantRoundByID(ctx, roundID) - if err != nil { - return nil, err - } - if err := checkGrantRoundAccess(ctx, doerID, round); err != nil { - return nil, err - } - if !isValidTransition(round.Status, target) { - return nil, fmt.Errorf("invalid transition from %d to %d for round %d", round.Status, target, roundID) - } - round.Status = target - if err := hackforger_model.UpdateGrantRound(ctx, round); err != nil { - return nil, err - } - return round, nil -} - -// CreateGrantRound creates a new grant round after verifying org access. -func CreateGrantRound(ctx context.Context, doerID, orgID int64, opts CreateGrantRoundOpts) (*hackforger_model.GrantRound, error) { - isOwner, err := org_model.IsOrganizationOwner(ctx, orgID, doerID) - if err != nil { - return nil, err - } - isAdmin, err := org_model.IsOrganizationAdmin(ctx, orgID, doerID) - if err != nil { - return nil, err - } - if !isOwner && !isAdmin { - return nil, fmt.Errorf("user %d is not an owner or admin of org %d", doerID, orgID) - } - - round := &hackforger_model.GrantRound{ - OrgID: orgID, - OwnerID: doerID, - Name: opts.Name, - Slug: opts.Slug, - Description: opts.Description, - Status: hackforger_model.GrantRoundStatusDraft, - Budget: opts.Budget, - Currency: opts.Currency, - BudgetCredits: opts.BudgetCredits, - Deadline: opts.Deadline, - } - if err := hackforger_model.CreateGrantRound(ctx, round); err != nil { - return nil, err - } - publishGrantEvent(ctx, doerID, hackforger_model.ActionGrantRoundCreated, round, AudienceGlobal) - return round, nil -} - -// CreateGrantRoundOpts holds options for creating a grant round. -type CreateGrantRoundOpts struct { - Name string - Slug string - Description string - Budget float64 - Currency string - BudgetCredits int64 - Deadline timeutil.TimeStamp -} - -// UpdateGrantRoundService updates a grant round (Draft or Open only). -func UpdateGrantRoundService(ctx context.Context, doerID int64, round *hackforger_model.GrantRound) error { - if err := checkGrantRoundAccess(ctx, doerID, round); err != nil { - return err - } - if round.Status != hackforger_model.GrantRoundStatusDraft && round.Status != hackforger_model.GrantRoundStatusOpen { - return fmt.Errorf("round %d can only be edited in Draft or Open status (current: %d)", round.ID, round.Status) - } - return hackforger_model.UpdateGrantRound(ctx, round) -} - -// DeleteGrantRoundService deletes a grant round (Draft only, with access check). -func DeleteGrantRoundService(ctx context.Context, doerID, roundID int64) error { - round, err := hackforger_model.GetGrantRoundByID(ctx, roundID) - if err != nil { - return err - } - if err := checkGrantRoundAccess(ctx, doerID, round); err != nil { - return err - } - return hackforger_model.DeleteGrantRound(ctx, roundID) -} - -// publishGrantEvent publishes a feed event for a grant action. -func publishGrantEvent(ctx context.Context, actUserID int64, opType activities_model.ActionType, round *hackforger_model.GrantRound, audience AudienceType) { - content := &hackforger_model.HackforgerActionContent{ - EntityType: "grant_round", - EntityID: round.ID, - EntityName: round.Name, - EntitySlug: round.Slug, - } - if err := PublishHackforgerAction(ctx, &HackforgerActionOpts{ - ActUserID: actUserID, - OpType: opType, - Content: content, - AudienceType: audience, - OrgID: round.OrgID, - }); err != nil { - log.Error("publishGrantEvent: %v", err) - } -} - -// OpenRound transitions a round from Draft to Open. -func OpenRound(ctx context.Context, doerID, roundID int64) error { - round, err := transitionRound(ctx, doerID, roundID, hackforger_model.GrantRoundStatusOpen) - if err != nil { - return err - } - publishGrantEvent(ctx, doerID, hackforger_model.ActionGrantRoundOpened, round, AudienceGlobal) - return nil -} - -// CloseRound transitions a round from Open to Review. -func CloseRound(ctx context.Context, doerID, roundID int64) error { - round, err := transitionRound(ctx, doerID, roundID, hackforger_model.GrantRoundStatusReview) - if err != nil { - return err - } - publishGrantEvent(ctx, doerID, hackforger_model.ActionGrantRoundClosed, round, AudienceGlobal) - return nil -} - -// FinalizeRound transitions a round from Review to Finalized. -// All Approved projects must have AwardAmount > 0 or AwardCredits > 0. -func FinalizeRound(ctx context.Context, doerID, roundID int64) error { - round, err := hackforger_model.GetGrantRoundByID(ctx, roundID) - if err != nil { - return err - } - if err := checkGrantRoundAccess(ctx, doerID, round); err != nil { - return err - } - if !isValidTransition(round.Status, hackforger_model.GrantRoundStatusFinalized) { - return fmt.Errorf("invalid transition from %d to finalized for round %d", round.Status, roundID) - } - - // Check all approved projects are allocated - approvedStatus := hackforger_model.GrantProjectStatusApproved - projects, _, err := hackforger_model.ListGrantProjectsByRound(ctx, hackforger_model.ListGrantProjectsByRoundOptions{ - RoundID: roundID, - Status: &approvedStatus, - }) - if err != nil { - return err - } - unallocated := int64(0) - for _, p := range projects { - if p.AwardAmount <= 0 && p.AwardCredits <= 0 { - unallocated++ - } - } - if unallocated > 0 { - return hackforger_model.ErrUnallocatedProjects{RoundID: roundID, Count: unallocated} - } - - round.Status = hackforger_model.GrantRoundStatusFinalized - if err := hackforger_model.UpdateGrantRound(ctx, round); err != nil { - return err - } - publishGrantEvent(ctx, doerID, hackforger_model.ActionGrantRoundFinalized, round, AudienceGlobal) - return nil -} - -// CancelRound cancels a round (from any non-terminal state). -func CancelRound(ctx context.Context, doerID, roundID int64) error { - round, err := transitionRound(ctx, doerID, roundID, hackforger_model.GrantRoundStatusCancelled) - if err != nil { - return err - } - publishGrantEvent(ctx, doerID, hackforger_model.ActionGrantRoundCancelled, round, AudienceGlobal) - return nil -} - -// SubmitProject creates a new project submission in an Open round. -func SubmitProject(ctx context.Context, doerID, roundID int64, opts SubmitProjectOpts) (*hackforger_model.GrantProject, error) { - round, err := hackforger_model.GetGrantRoundByID(ctx, roundID) - if err != nil { - return nil, err - } - if round.Status != hackforger_model.GrantRoundStatusOpen { - return nil, hackforger_model.ErrGrantRoundNotOpen{RoundID: roundID, Status: round.Status} - } - - // Check duplicate - _, err = hackforger_model.GetGrantProjectByUserAndRound(ctx, doerID, roundID) - if err == nil { - return nil, hackforger_model.ErrGrantProjectAlreadyExists{UserID: doerID, RoundID: roundID} - } - if !hackforger_model.IsErrGrantProjectNotExist(err) { - return nil, err - } - - project := &hackforger_model.GrantProject{ - RoundID: roundID, - UserID: doerID, - RepoID: opts.RepoID, - Title: opts.Title, - Description: opts.Description, - Status: hackforger_model.GrantProjectStatusPending, - } - if err := hackforger_model.CreateGrantProject(ctx, project); err != nil { - return nil, err - } - - publishGrantEvent(ctx, doerID, hackforger_model.ActionGrantProjectSubmitted, round, AudienceFollowers) - return project, nil -} - -// ApproveProject approves a pending project. -func ApproveProject(ctx context.Context, doerID, projectID int64) error { - project, err := hackforger_model.GetGrantProjectByID(ctx, projectID) - if err != nil { - return err - } - round, err := hackforger_model.GetGrantRoundByID(ctx, project.RoundID) - if err != nil { - return err - } - if err := checkGrantRoundAccess(ctx, doerID, round); err != nil { - return err - } - if project.Status != hackforger_model.GrantProjectStatusPending { - return fmt.Errorf("project %d is not pending (status: %d)", projectID, project.Status) - } - project.Status = hackforger_model.GrantProjectStatusApproved - return hackforger_model.UpdateGrantProject(ctx, project) -} - -// RejectProject rejects a pending project. -func RejectProject(ctx context.Context, doerID, projectID int64) error { - project, err := hackforger_model.GetGrantProjectByID(ctx, projectID) - if err != nil { - return err - } - round, err := hackforger_model.GetGrantRoundByID(ctx, project.RoundID) - if err != nil { - return err - } - if err := checkGrantRoundAccess(ctx, doerID, round); err != nil { - return err - } - if project.Status != hackforger_model.GrantProjectStatusPending { - return fmt.Errorf("project %d is not pending (status: %d)", projectID, project.Status) - } - project.Status = hackforger_model.GrantProjectStatusRejected - return hackforger_model.UpdateGrantProject(ctx, project) -} - -// AllocateAward sets the award amounts for an approved project with budget validation. -func AllocateAward(ctx context.Context, doerID, projectID int64, amount float64, credits int64) error { - project, err := hackforger_model.GetGrantProjectByID(ctx, projectID) - if err != nil { - return err - } - round, err := hackforger_model.GetGrantRoundByID(ctx, project.RoundID) - if err != nil { - return err - } - if err := checkGrantRoundAccess(ctx, doerID, round); err != nil { - return err - } - if round.Status != hackforger_model.GrantRoundStatusReview { - return fmt.Errorf("round %d is not in review status", round.ID) - } - if project.Status != hackforger_model.GrantProjectStatusApproved { - return fmt.Errorf("project %d is not approved", projectID) - } - - // Budget check: get current usage, subtract this project's existing allocation, add new - usedAmount, usedCredits, err := hackforger_model.GetGrantRoundBudgetUsage(ctx, round.ID) - if err != nil { - return err - } - // Subtract current project allocation (in case of re-allocation) - usedAmount -= project.AwardAmount - usedCredits -= project.AwardCredits - - if usedAmount+amount > round.Budget { - return hackforger_model.ErrExceedsBudget{ - RoundID: round.ID, BudgetField: "amount", - Budget: round.Budget, Used: usedAmount, Requested: amount, - } - } - if usedCredits+credits > round.BudgetCredits { - return hackforger_model.ErrExceedsBudget{ - RoundID: round.ID, BudgetField: "credits", - Budget: float64(round.BudgetCredits), Used: float64(usedCredits), Requested: float64(credits), - } - } - - project.AwardAmount = amount - project.AwardCredits = credits - return hackforger_model.UpdateGrantProject(ctx, project) -} - -// DistributeProject marks a project as Funded and deposits credits. -func DistributeProject(ctx context.Context, doerID, projectID int64) error { - project, err := hackforger_model.GetGrantProjectByID(ctx, projectID) - if err != nil { - return err - } - round, err := hackforger_model.GetGrantRoundByID(ctx, project.RoundID) - if err != nil { - return err - } - if err := checkGrantRoundAccess(ctx, doerID, round); err != nil { - return err - } - if round.Status != hackforger_model.GrantRoundStatusFinalized { - return fmt.Errorf("round %d is not finalized", round.ID) - } - if project.Status != hackforger_model.GrantProjectStatusApproved { - return fmt.Errorf("project %d is not approved (status: %d)", projectID, project.Status) - } - - err = db.WithTx(ctx, func(ctx context.Context) error { - // Deposit credits - if project.AwardCredits > 0 { - ref := fmt.Sprintf("grant_round:%d/project:%d", round.ID, project.ID) - if err := Deposit(ctx, project.UserID, project.AwardCredits, ref, fmt.Sprintf("Grant award: %s", round.Name)); err != nil { - return err - } - } - // Mark project as Funded - project.Status = hackforger_model.GrantProjectStatusFunded - return hackforger_model.UpdateGrantProject(ctx, project) - }) - if err != nil { - return err - } - - publishGrantEvent(ctx, doerID, hackforger_model.ActionGrantAwarded, round, AudienceGlobal) - - // Auto-check: if all Approved projects are now Funded, transition round - approvedStatus := hackforger_model.GrantProjectStatusApproved - remaining, err := hackforger_model.CountGrantProjectsByRound(ctx, round.ID, &approvedStatus) - if err != nil { - log.Error("DistributeProject auto-check: %v", err) - return nil // don't fail the distribute - } - if remaining == 0 { - round.Status = hackforger_model.GrantRoundStatusDistributed - if err := hackforger_model.UpdateGrantRound(ctx, round); err != nil { - log.Error("DistributeProject auto-distribute round: %v", err) - } - } - - return nil -} - -// DistributeRound distributes all approved+allocated projects in a finalized round. -func DistributeRound(ctx context.Context, doerID, roundID int64) error { - round, err := hackforger_model.GetGrantRoundByID(ctx, roundID) - if err != nil { - return err - } - if err := checkGrantRoundAccess(ctx, doerID, round); err != nil { - return err - } - if round.Status != hackforger_model.GrantRoundStatusFinalized { - return fmt.Errorf("round %d is not finalized", roundID) - } - - approvedStatus := hackforger_model.GrantProjectStatusApproved - projects, _, err := hackforger_model.ListGrantProjectsByRound(ctx, hackforger_model.ListGrantProjectsByRoundOptions{ - RoundID: roundID, - Status: &approvedStatus, - }) - if err != nil { - return err - } - - for _, p := range projects { - if err := DistributeProject(ctx, doerID, p.ID); err != nil { - return fmt.Errorf("failed to distribute project %d: %w", p.ID, err) - } - } - return nil -} - -// ExportRoundCSV generates a CSV export of the round's projects. -func ExportRoundCSV(ctx context.Context, roundID int64) ([]byte, error) { - projects, _, err := hackforger_model.ListGrantProjectsByRound(ctx, hackforger_model.ListGrantProjectsByRoundOptions{ - RoundID: roundID, - }) - if err != nil { - return nil, err - } - - var buf bytes.Buffer - w := csv.NewWriter(&buf) - _ = w.Write([]string{"id", "title", "user_id", "status", "award_amount", "award_credits"}) - for _, p := range projects { - _ = w.Write([]string{ - fmt.Sprintf("%d", p.ID), - p.Title, - fmt.Sprintf("%d", p.UserID), - fmt.Sprintf("%d", p.Status), - fmt.Sprintf("%.2f", p.AwardAmount), - fmt.Sprintf("%d", p.AwardCredits), - }) - } - w.Flush() - return buf.Bytes(), w.Error() -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-grant && go test ./services/hackforger/... -run "TestGrantRound|TestSubmitProject|TestAllocateAward|TestFinalizeRound|TestDistributeProject|TestCancelRound" -v` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add services/hackforger/grants.go services/hackforger/grants_test.go -git commit -m "feat(grant): add Grant service — state machine, budget validation, distribute + tests" -``` - ---- - -## Chunk 4: Service Layer — Credits Admin - -### Task 7: Add Credits Admin Functions - -**Files:** -- Modify: `services/hackforger/credits.go` -- Create: `services/hackforger/credits_test.go` - -- [ ] **Step 1: Write failing tests** - -Create `services/hackforger/credits_test.go`: - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package hackforger_test - -import ( - "testing" - - "forgejo.org/models/db" - hackforger_model "forgejo.org/models/hackforger" - "forgejo.org/models/unittest" - user_model "forgejo.org/models/user" - hackforger_service "forgejo.org/services/hackforger" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAdminDeposit(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) // admin user from Forgejo fixtures - - err := hackforger_service.AdminDeposit(db.DefaultContext, admin, 4, 500, "manual", "test deposit") - require.NoError(t, err) - - acct, err := hackforger_model.GetCreditAccount(db.DefaultContext, 4) - require.NoError(t, err) - assert.Equal(t, int64(1500), acct.Balance) // 1000 + 500 -} - -func TestAdminDeposit_NotAdmin(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - nonAdmin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) - - err := hackforger_service.AdminDeposit(db.DefaultContext, nonAdmin, 4, 500, "manual", "should fail") - assert.Error(t, err) -} - -func TestAdminDeduct(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) - - err := hackforger_service.AdminDeduct(db.DefaultContext, admin, 4, 200, "manual", "test deduct") - require.NoError(t, err) - - acct, err := hackforger_model.GetCreditAccount(db.DefaultContext, 4) - require.NoError(t, err) - assert.Equal(t, int64(800), acct.Balance) // 1000 - 200 -} - -func TestAdminDeduct_InsufficientBalance(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) - - err := hackforger_service.AdminDeduct(db.DefaultContext, admin, 4, 2000, "manual", "too much") - assert.True(t, hackforger_model.IsErrInsufficientCredits(err)) -} - -func TestFulfillOrder(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) - - err := hackforger_service.FulfillOrder(db.DefaultContext, admin, 1, "Sent via email") - require.NoError(t, err) - - order, err := hackforger_model.GetRedeemOrderByID(db.DefaultContext, 1) - require.NoError(t, err) - assert.Equal(t, hackforger_model.OrderStatusFulfilled, order.Status) - assert.Equal(t, "Sent via email", order.FulfillNote) -} - -func TestCancelOrder_RefundsBalance(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) - - // Order 1 costs 500, user 4 has balance 1000 - err := hackforger_service.CancelOrder(db.DefaultContext, admin, 1) - require.NoError(t, err) - - order, err := hackforger_model.GetRedeemOrderByID(db.DefaultContext, 1) - require.NoError(t, err) - assert.Equal(t, hackforger_model.OrderStatusCancelled, order.Status) - - // Balance should be refunded: 1000 + 500 = 1500 - acct, err := hackforger_model.GetCreditAccount(db.DefaultContext, 4) - require.NoError(t, err) - assert.Equal(t, int64(1500), acct.Balance) -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-grant && go test ./services/hackforger/... -run "TestAdmin|TestFulfillOrder|TestCancelOrder" -v` -Expected: FAIL - -- [ ] **Step 3: Implement admin functions** - -Append to `services/hackforger/credits.go`: - -```go -import ( - user_model "forgejo.org/models/user" -) - -// AdminDeposit manually adds credits to a user (admin only). -func AdminDeposit(ctx context.Context, admin *user_model.User, userID int64, amount int64, reference, note string) error { - if !admin.IsAdmin { - return fmt.Errorf("user %d is not an admin", admin.ID) - } - return Deposit(ctx, userID, amount, reference, note) -} - -// AdminDeduct manually removes credits from a user (admin only). -func AdminDeduct(ctx context.Context, admin *user_model.User, userID int64, amount int64, reference, note string) error { - if !admin.IsAdmin { - return fmt.Errorf("user %d is not an admin", admin.ID) - } - return db.WithTx(ctx, func(ctx context.Context) error { - acct, err := GetOrCreateCreditAccount(ctx, userID) - if err != nil { - return err - } - if acct.Balance < amount { - return hackforger_model.ErrInsufficientCredits{ - UserID: userID, Balance: acct.Balance, Requested: amount, - } - } - acct.Balance -= amount - if _, err := db.GetEngine(ctx).ID(acct.ID).Cols("balance").Update(acct); err != nil { - return err - } - tx := &hackforger_model.CreditTransaction{ - UserID: userID, - Type: hackforger_model.TransactionTypeWithdraw, - Amount: -amount, - Balance: acct.Balance, - Reference: reference, - Note: note, - } - _, err = db.GetEngine(ctx).Insert(tx) - return err - }) -} - -// FulfillOrder marks a redeem order as fulfilled (admin only). -func FulfillOrder(ctx context.Context, admin *user_model.User, orderID int64, note string) error { - if !admin.IsAdmin { - return fmt.Errorf("user %d is not an admin", admin.ID) - } - order, err := hackforger_model.GetRedeemOrderByID(ctx, orderID) - if err != nil { - return err - } - if order.Status != hackforger_model.OrderStatusPending { - return fmt.Errorf("order %d is not pending (status: %s)", orderID, order.Status) - } - order.Status = hackforger_model.OrderStatusFulfilled - order.FulfillNote = note - return hackforger_model.UpdateRedeemOrder(ctx, order) -} - -// CancelOrder cancels a redeem order and refunds the credits (admin only). -func CancelOrder(ctx context.Context, admin *user_model.User, orderID int64) error { - if !admin.IsAdmin { - return fmt.Errorf("user %d is not an admin", admin.ID) - } - order, err := hackforger_model.GetRedeemOrderByID(ctx, orderID) - if err != nil { - return err - } - if order.Status != hackforger_model.OrderStatusPending { - return fmt.Errorf("order %d is not pending (status: %s)", orderID, order.Status) - } - return db.WithTx(ctx, func(ctx context.Context) error { - order.Status = hackforger_model.OrderStatusCancelled - if err := hackforger_model.UpdateRedeemOrder(ctx, order); err != nil { - return err - } - // Refund balance - acct, err := GetOrCreateCreditAccount(ctx, order.UserID) - if err != nil { - return err - } - acct.Balance += order.Cost - if _, err := db.GetEngine(ctx).ID(acct.ID).Cols("balance").Update(acct); err != nil { - return err - } - tx := &hackforger_model.CreditTransaction{ - UserID: order.UserID, - Type: hackforger_model.TransactionTypeRefund, - Amount: order.Cost, - Balance: acct.Balance, - Reference: fmt.Sprintf("order:%d", order.ID), - Note: "Order cancelled, credits refunded", - } - _, err = db.GetEngine(ctx).Insert(tx) - return err - }) -} - -// CreateRedeemOptionAsAdmin creates a new redeem option (admin only). -func CreateRedeemOptionAsAdmin(ctx context.Context, admin *user_model.User, opt *hackforger_model.RedeemOption) error { - if !admin.IsAdmin { - return fmt.Errorf("user %d is not an admin", admin.ID) - } - return hackforger_model.CreateRedeemOption(ctx, opt) -} - -// UpdateRedeemOptionAsAdmin updates a redeem option (admin only). -func UpdateRedeemOptionAsAdmin(ctx context.Context, admin *user_model.User, opt *hackforger_model.RedeemOption) error { - if !admin.IsAdmin { - return fmt.Errorf("user %d is not an admin", admin.ID) - } - return hackforger_model.UpdateRedeemOption(ctx, opt) -} -``` - -Note: Merge new imports with existing ones in `credits.go`. The file already imports `"forgejo.org/models/db"` and `hackforger_model`. Add `user_model` and `"fmt"`. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-grant && go test ./services/hackforger/... -run "TestAdmin|TestFulfillOrder|TestCancelOrder" -v` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add services/hackforger/credits.go services/hackforger/credits_test.go -git commit -m "feat(credits): add admin deposit/deduct, fulfill/cancel order + tests" -``` - ---- - -## Chunk 5: API Layer - -### Task 8: Grant API Handlers - -**Files:** -- Modify: `routers/api/v1/hackforger/grants.go` (replace stub) -- Modify: `routers/api/v1/api.go:1739-1746` - -- [ ] **Step 1: Implement Grant API handlers** - -Replace `routers/api/v1/hackforger/grants.go` entirely. The file should contain all Grant Round and Grant Project API handlers with Swagger annotations. Each handler: -- Uses `context.APIContext` -- Parses path params with `ctx.PathParamInt64(":id")` -- Uses `ctx.JSON(status, data)` for responses -- Uses `ctx.Error(status, msg, err)` for errors -- Includes swagger operation comments - -Key handlers to implement: -- `CreateGrantRound` — parse JSON body, call `hackforger_service.CreateGrantRound` (note: service-level CreateGrantRound needs to be added as a thin wrapper that creates the round, publishes feed event, and returns it) -- `ListGrantRounds` — use `utils.GetListOptions(ctx)`, call model `ListGrantRounds`, set `X-Total-Count` -- `GetGrantRound` — by ID -- `UpdateGrantRound` — parse body, call service -- `DeleteGrantRound` — call model -- `OpenRound`, `CloseRound`, `FinalizeRound`, `DistributeRound`, `CancelRound` — call service functions -- `ExportCSV` — set Content-Type `text/csv`, return bytes -- `SubmitProject`, `ListProjects`, `GetProject`, `ApproveOrReject`, `AllocateAward`, `DistributeProject` - -Also add a `GrantRoutes` function that registers all routes in a go-chi group: - -```go -func GrantRoutes(m *macaron.Macaron) { - // This will actually use go-chi router pattern, following existing HackForger API registration -} -``` - -- [ ] **Step 2: Update API route registration** - -**Note:** The existing P0 code registers grants at `/grants/rounds` (line 1743). This plan changes it to `/grant-rounds` (kebab-case, matching the spec). This is an intentional path change. - -In `routers/api/v1/api.go:1739-1746`, replace the entire `/hackforger` group: - -```go -// HackForger API routes -m.Group("/hackforger", func() { - m.Get("/hackathons", hackforger_api.ListHackathons) - m.Get("/bounties", hackforger_api.ListBounties) - m.Get("/feed", hackforger_api.GetFeed) - - // Grant Round routes - m.Group("/grant-rounds", func() { - m.Combo(""). - Get(hackforger_api.ListGrantRounds). - Post(reqToken(), hackforger_api.CreateGrantRound) - m.Group("/{id}", func() { - m.Get("", hackforger_api.GetGrantRound) - m.Put("", reqToken(), hackforger_api.UpdateGrantRound) - m.Delete("", reqToken(), hackforger_api.DeleteGrantRound) - m.Post("/open", reqToken(), hackforger_api.OpenRound) - m.Post("/close", reqToken(), hackforger_api.CloseRound) - m.Post("/finalize", reqToken(), hackforger_api.FinalizeRound) - m.Post("/distribute", reqToken(), hackforger_api.DistributeRound) - m.Post("/cancel", reqToken(), hackforger_api.CancelRound) - m.Get("/export", hackforger_api.ExportRoundCSV) - m.Group("/projects", func() { - m.Combo(""). - Get(hackforger_api.ListGrantProjects). - Post(reqToken(), hackforger_api.SubmitProject) - m.Group("/{pid}", func() { - m.Get("", hackforger_api.GetGrantProject) - m.Put("", reqToken(), hackforger_api.ApproveOrRejectProject) - m.Put("/award", reqToken(), hackforger_api.AllocateAward) - m.Post("/distribute", reqToken(), hackforger_api.DistributeProject) - }) - }) - }) - }) - - // Credits routes (Task 9) - m.Group("/credits", func() { - m.Get("/balance", reqToken(), hackforger_api.GetBalance) - m.Get("/transactions", reqToken(), hackforger_api.ListTransactions) - m.Group("/redeem", func() { - m.Get("/options", hackforger_api.ListRedeemOptions) - m.Post("/options", reqToken(), reqSiteAdmin(), hackforger_api.CreateRedeemOption) - m.Put("/options/{id}", reqToken(), reqSiteAdmin(), hackforger_api.UpdateRedeemOption) - m.Post("", reqToken(), hackforger_api.Redeem) - m.Get("/orders", reqToken(), hackforger_api.ListRedeemOrders) - m.Post("/orders/{oid}/fulfill", reqToken(), reqSiteAdmin(), hackforger_api.FulfillOrder) - m.Post("/orders/{oid}/cancel", reqToken(), reqSiteAdmin(), hackforger_api.CancelOrder) - }) - m.Group("/admin", func() { - m.Post("/deposit", hackforger_api.AdminDeposit) - m.Post("/deduct", hackforger_api.AdminDeduct) - }, reqToken(), reqSiteAdmin()) - }) -}) -``` - -- [ ] **Step 3: Verify compilation** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-grant && go build ./routers/...` -Expected: Success - -- [ ] **Step 4: Commit** - -```bash -git add routers/api/v1/hackforger/grants.go routers/api/v1/api.go -git commit -m "feat(grant): add Grant API handlers + route registration" -``` - -### Task 9: Credits API Handlers - -**Files:** -- Modify: `routers/api/v1/hackforger/credits.go` (replace stub) - -- [ ] **Step 1: Implement Credits API handlers** - -Replace `routers/api/v1/hackforger/credits.go`. Handlers: -- `GetBalance` — call `GetOrCreateCreditAccount`, return `{"balance": N}` -- `ListTransactions` — paginated, `X-Total-Count` -- `ListRedeemOptions` — call `ListRedeemOptions` -- `Redeem` — parse `{"option_id": N}`, call `Redeem` -- `ListRedeemOrders` — paginated -- `AdminDeposit` — parse body, call `AdminDeposit` -- `AdminDeduct` — parse body, call `AdminDeduct` -- `CreateRedeemOption` — parse body -- `UpdateRedeemOption` — parse body -- `FulfillOrder` — parse body with note -- `CancelOrder` — call `CancelOrder` - -All with Swagger annotations. - -- [ ] **Step 2: Verify compilation** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-grant && go build ./routers/...` -Expected: Success - -- [ ] **Step 3: Commit** - -```bash -git add routers/api/v1/hackforger/credits.go -git commit -m "feat(credits): add Credits API handlers" -``` - ---- - -## Chunk 6: Web Routes + Templates (Grants) - -### Task 10: Grant Web Handlers + Route Registration - -**Files:** -- Create: `routers/web/hackforger/grants.go` -- Modify: `routers/web/hackforger/hackathon.go` (remove ExploreGrants) -- Modify: `routers/web/web.go` (add grant route groups) - -- [ ] **Step 1: Create grants.go web handler** - -Create `routers/web/hackforger/grants.go` with handlers: -- `ExploreGrants` — moved from hackathon.go, now queries actual data -- `NewGrantRound` (GET + POST) -- `GrantRoundDetail` -- `GrantRoundProjects` -- `SubmitProject` (GET + POST) -- `ManageRound` -- `ManageProject` -- `ExportCSV` - -Each handler sets `ctx.Data` variables and renders the appropriate template. - -- [ ] **Step 2: Remove ExploreGrants from hackathon.go** - -In `routers/web/hackforger/hackathon.go`, remove the `ExploreGrants` function (lines 25-29). Keep `ExploreHackathons` and `ExploreBounties`. - -- [ ] **Step 3: Register web routes** - -In `routers/web/web.go`, after the `/explore` group (around line 507), add: - -```go -m.Group("/grants", func() { - m.Combo("/new").Get(hackforger_web.NewGrantRound).Post(web.Bind(forms.CreateGrantRoundForm{}), hackforger_web.NewGrantRoundPost) - m.Group("/{slug}", func() { - m.Get("", hackforger_web.GrantRoundDetail) - m.Get("/projects", hackforger_web.GrantRoundProjects) - m.Combo("/submit").Get(hackforger_web.SubmitGrantProject).Post(web.Bind(forms.SubmitGrantProjectForm{}), hackforger_web.SubmitGrantProjectPost) - m.Get("/export", hackforger_web.ExportGrantRoundCSV) - m.Group("/manage", func() { - m.Get("", hackforger_web.ManageGrantRound) - m.Post("/open", hackforger_web.ManageGrantRoundOpen) - m.Post("/close", hackforger_web.ManageGrantRoundClose) - m.Post("/finalize", hackforger_web.ManageGrantRoundFinalize) - m.Post("/distribute", hackforger_web.ManageGrantRoundDistribute) - m.Post("/cancel", hackforger_web.ManageGrantRoundCancel) - m.Group("/projects/{pid}", func() { - m.Get("", hackforger_web.ManageGrantProject) - m.Post("/approve", hackforger_web.ManageGrantProjectApprove) - m.Post("/reject", hackforger_web.ManageGrantProjectReject) - m.Post("/award", hackforger_web.ManageGrantProjectAward) - m.Post("/distribute", hackforger_web.ManageGrantProjectDistribute) - }) - }) - }) -}, reqSignIn) -``` - -Note: Form binding structs need to be created. If Forgejo uses `services/forms/` for form definitions, create the form structs there. Otherwise define them inline or in a hackforger-specific forms file. - -- [ ] **Step 4: Verify compilation** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-grant && go build ./routers/...` -Expected: Success - -- [ ] **Step 5: Commit** - -```bash -git add routers/web/hackforger/grants.go routers/web/hackforger/hackathon.go routers/web/web.go -git commit -m "feat(grant): add web handlers + route registration" -``` - -### Task 11: Grant Templates - -**Files:** -- Create: `templates/hackforger/grants/explore.tmpl` -- Create: `templates/hackforger/grants/new.tmpl` -- Create: `templates/hackforger/grants/detail.tmpl` -- Create: `templates/hackforger/grants/projects.tmpl` -- Create: `templates/hackforger/grants/submit.tmpl` -- Create: `templates/hackforger/grants/manage.tmpl` -- Create: `templates/hackforger/grants/manage_project.tmpl` -- Modify: `templates/hackforger/explore.tmpl` - -- [ ] **Step 1: Create grant templates** - -Each template follows the pattern: -```html -{{template "base/head" .}} -
- -
-{{template "base/footer" .}} -``` - -Key templates: - -**explore.tmpl** — Grant list with status filter tabs, pagination. Uses Fomantic UI card layout. - -**detail.tmpl** — Status progress bar (6 steps), round info (name, desc, budget, deadline, org), "Submit Project" button when Open, project count stats. - -**manage.tmpl** — State action buttons (contextual based on current status), project table with status badges, budget usage progress bar. - -**manage_project.tmpl** — Project details, approve/reject buttons, award amount/credits input fields. - -- [ ] **Step 2: Update explore.tmpl to include grants tab** - -Modify `templates/hackforger/explore.tmpl` to check `PageIsExploreGrants` and render the grants list content instead of "Coming soon". - -- [ ] **Step 3: Verify templates render** - -Start server and navigate to `/explore/grants` to verify the page renders. - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-grant && TAGS="bindata sqlite sqlite_unlock_notify" make backend && ./gitea web` -Navigate to: `http://localhost:3000/explore/grants` - -- [ ] **Step 4: Commit** - -```bash -git add templates/hackforger/grants/ templates/hackforger/explore.tmpl -git commit -m "feat(grant): add grant templates — explore, detail, manage, submit" -``` - ---- - -## Chunk 7: Web Routes + Templates (Credits) - -### Task 12: Credits Web Handlers - -**Files:** -- Create: `routers/web/hackforger/credits.go` -- Modify: `routers/web/web.go` (add credits + admin routes) - -- [ ] **Step 1: Create credits.go web handler** - -Create `routers/web/hackforger/credits.go` with handlers: -- `CreditsOverview` — balance, transactions, redeem options -- `RedeemConfirm` (GET + POST) -- `OrderList` -- `AdminCredits` — user account list, deposit/deduct forms -- `AdminRedeemOptions` — CRUD for options -- `AdminOrders` — order list + fulfill/cancel - -- [ ] **Step 2: Register web routes** - -In `routers/web/web.go`, add credits routes: - -```go -m.Group("/credits", func() { - m.Get("", hackforger_web.CreditsOverview) - m.Combo("/redeem/{id}").Get(hackforger_web.RedeemConfirm).Post(hackforger_web.RedeemConfirmPost) - m.Get("/orders", hackforger_web.CreditOrders) -}, reqSignIn) -``` - -And admin routes in the existing `/-/admin` group: - -```go -m.Group("/credits", func() { - m.Get("", hackforger_web.AdminCredits) - m.Post("/deposit", hackforger_web.AdminCreditsDeposit) - m.Post("/deduct", hackforger_web.AdminCreditsDeduct) - m.Get("/options", hackforger_web.AdminRedeemOptions) - m.Post("/options", hackforger_web.AdminRedeemOptionsCreate) - m.Post("/options/{id}", hackforger_web.AdminRedeemOptionsUpdate) - m.Get("/orders", hackforger_web.AdminCreditOrders) - m.Post("/orders/{oid}/fulfill", hackforger_web.AdminCreditOrdersFulfill) - m.Post("/orders/{oid}/cancel", hackforger_web.AdminCreditOrdersCancel) -}) -``` - -- [ ] **Step 3: Verify compilation** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-grant && go build ./routers/...` - -- [ ] **Step 4: Commit** - -```bash -git add routers/web/hackforger/credits.go routers/web/web.go -git commit -m "feat(credits): add web handlers + route registration" -``` - -### Task 13: Credits Templates - -**Files:** -- Create: `templates/hackforger/credits/overview.tmpl` -- Create: `templates/hackforger/credits/redeem.tmpl` -- Create: `templates/hackforger/credits/orders.tmpl` -- Create: `templates/hackforger/credits/admin/credits.tmpl` -- Create: `templates/hackforger/credits/admin/options.tmpl` -- Create: `templates/hackforger/credits/admin/orders.tmpl` - -- [ ] **Step 1: Create credits templates** - -**overview.tmpl** — Balance card at top, transaction list table (paginated), redeem options grid with "Redeem" buttons. - -**redeem.tmpl** — Confirmation page showing option name, cost, current balance, confirm button. - -**orders.tmpl** — Order list with status badges (pending/fulfilled/cancelled). - -**admin/credits.tmpl** — User search + account list, deposit/deduct form. - -**admin/options.tmpl** — Table of redeem options, create/edit forms. - -**admin/orders.tmpl** — All orders table with fulfill/cancel action buttons. - -- [ ] **Step 2: Verify templates render** - -Start server and navigate to `/credits` (as logged-in user) and `/-/admin/credits` (as admin). - -- [ ] **Step 3: Commit** - -```bash -git add templates/hackforger/credits/ -git commit -m "feat(credits): add credits templates — overview, redeem, orders, admin" -``` - ---- - -## Chunk 8: i18n + Final Integration - -### Task 14: Add i18n Keys - -**Files:** -- Modify: `options/locale/locale_en-US.ini` - -- [ ] **Step 1: Append grant + credits i18n keys** - -Append to the `[hackforger]` section in `options/locale/locale_en-US.ini`: - -```ini -; Grant Round -grant.round.new = New Grant Round -grant.round.edit = Edit Grant Round -grant.round.name = Name -grant.round.slug = Slug -grant.round.description = Description -grant.round.budget = Budget -grant.round.budget_credits = Credits Budget -grant.round.currency = Currency -grant.round.deadline = Deadline -grant.round.organization = Organization -grant.round.status = Status -grant.round.status.draft = Draft -grant.round.status.open = Open -grant.round.status.review = Reviewing -grant.round.status.finalized = Finalized -grant.round.status.distributed = Distributed -grant.round.status.cancelled = Cancelled -grant.round.action.open = Open Round -grant.round.action.close = Close Applications -grant.round.action.finalize = Finalize Allocations -grant.round.action.distribute = Distribute All -grant.round.action.cancel = Cancel Round -grant.round.budget_usage = Budget Usage -grant.round.no_rounds = No grant rounds found. -grant.round.create_success = Grant round created successfully. -grant.round.export = Export CSV - -; Grant Project -grant.project.submit = Submit Project -grant.project.title = Project Title -grant.project.description = Description -grant.project.repo = Repository -grant.project.status.pending = Pending -grant.project.status.approved = Approved -grant.project.status.rejected = Rejected -grant.project.status.funded = Funded -grant.project.approve = Approve -grant.project.reject = Reject -grant.project.award_amount = Award Amount -grant.project.award_credits = Award Credits -grant.project.allocate = Allocate Award -grant.project.distribute = Distribute -grant.project.no_projects = No projects submitted yet. -grant.project.submit_success = Project submitted successfully. -grant.project.already_submitted = You have already submitted a project to this round. - -; Credits (additional keys) -credits.overview = Credits Overview -credits.your_balance = Your Balance -credits.transaction_history = Transaction History -credits.transaction.type = Type -credits.transaction.amount = Amount -credits.transaction.balance_after = Balance After -credits.transaction.reference = Reference -credits.transaction.date = Date -credits.redeem.confirm = Confirm Redemption -credits.redeem.confirm_message = Are you sure you want to redeem "%s" for %d credits? -credits.redeem.success = Successfully redeemed! -credits.redeem.insufficient = Insufficient credits. -credits.orders.title = My Orders -credits.orders.status.pending = Pending -credits.orders.status.fulfilled = Fulfilled -credits.orders.status.cancelled = Cancelled -credits.admin.title = Credits Administration -credits.admin.deposit = Deposit Credits -credits.admin.deduct = Deduct Credits -credits.admin.user_id = User ID -credits.admin.amount = Amount -credits.admin.reference = Reference -credits.admin.note = Note -credits.admin.options = Manage Redeem Options -credits.admin.orders = Manage Orders -credits.admin.fulfill = Fulfill -credits.admin.cancel = Cancel -credits.admin.fulfill_note = Fulfillment Note -``` - -- [ ] **Step 2: Verify no syntax errors** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-grant && go build ./...` - -- [ ] **Step 3: Commit** - -```bash -git add options/locale/locale_en-US.ini -git commit -m "i18n: add Grant + Credits locale keys" -``` - -### Task 15: Create E2E Manual Test Prompt - -**Files:** -- Create: `docs/tests/e2e/phase1-grant-e2e.md` - -- [ ] **Step 1: Write E2E test prompt and report template** - -Create `docs/tests/e2e/phase1-grant-e2e.md` with: -- Prerequisites (server running, admin login) -- Step-by-step manual test flow: - 1. Login as admin, create org - 2. Create grant round (verify Draft status) - 3. Open round (verify status change) - 4. Login as hacker, submit project - 5. Try duplicate submission (verify error) - 6. Login as admin, close round - 7. Approve project, allocate award - 8. Try exceeding budget (verify error) - 9. Finalize round - 10. Distribute project (verify credits deposited) - 11. Verify round auto-transitions to Distributed - 12. Test credits: check balance, redeem, verify order - 13. Admin: deposit/deduct, fulfill order - 14. Verify feed events at each step (global feed) -- Report template with pass/fail checkboxes - -- [ ] **Step 2: Commit** - -```bash -git add docs/tests/e2e/phase1-grant-e2e.md -git commit -m "docs: add Phase 1 Grant + Credits E2E test prompt" -``` - -### Task 16: API Integration Tests - -**Files:** -- Create: `tests/integration/hackforger_grant_test.go` -- Create: `tests/integration/hackforger_credits_test.go` - -- [ ] **Step 1: Create grant integration tests** - -Create `tests/integration/hackforger_grant_test.go` using the existing integration test framework (`tests/integration/integration_test.go` for setup patterns). Tests should use `MakeRequest` and `DecodeJSON` helpers. - -Tests to implement: -- `TestAPIGrantRoundCRUD` — create, get, list, update, delete via API -- `TestAPIGrantRoundStatusFlow` — open, close, finalize, distribute via API -- `TestAPIGrantProjectSubmitAndApprove` — submit project, approve via API -- `TestAPIAllocateAndFinalize` — allocate award, finalize, verify budget check -- `TestAPIDistributeProject_CreditsReceived` — distribute, verify credits balance via API -- `TestAPIDistributeRound_BatchAll` — batch distribute all projects -- `TestAPICancelRound` — cancel from various states -- `TestAPIExportCSV` — verify CSV content type and content - -- [ ] **Step 2: Create credits integration tests** - -Create `tests/integration/hackforger_credits_test.go`: -- `TestAPICreditsBalance` — get balance as logged-in user -- `TestAPIRedeem` — redeem option, verify balance deducted and order created -- `TestAPIAdminDeposit` — admin deposits credits, verify balance -- `TestAPIAdminFulfillOrder` — admin fulfills order - -- [ ] **Step 3: Run integration tests** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-grant && go test ./tests/integration/... -run "TestAPIGrant|TestAPICredits" -v` -Expected: PASS - -- [ ] **Step 4: Commit** - -```bash -git add tests/integration/hackforger_grant_test.go tests/integration/hackforger_credits_test.go -git commit -m "test: add Grant + Credits API integration tests" -``` - -### Task 17: Run All Tests - -- [ ] **Step 1: Run model tests** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-grant && go test ./models/hackforger/... -v` -Expected: All tests PASS - -- [ ] **Step 2: Run service tests** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-grant && go test ./services/hackforger/... -v` -Expected: All tests PASS - -- [ ] **Step 3: Run full backend build** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-grant && TAGS="bindata sqlite sqlite_unlock_notify" make backend` -Expected: Success - -- [ ] **Step 4: Commit any fixes** - -If any tests or build issues arise, fix and commit. diff --git a/docs/superpowers/plans/2026-03-28-phase1-hackathon.md b/docs/superpowers/plans/2026-03-28-phase1-hackathon.md deleted file mode 100644 index 6547c53301..0000000000 --- a/docs/superpowers/plans/2026-03-28-phase1-hackathon.md +++ /dev/null @@ -1,3625 +0,0 @@ -# Phase 1 Hackathon Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Implement the full Hackathon lifecycle (create, register, submit, judge, finalize) with 26 API endpoints, 8 web pages, and 6 Feed events. - -**Architecture:** Forgejo's strict layered architecture — models (CRUD) → services (business logic) → routers (HTTP). New `HackathonJudge` table for judge assignments. State machine in services layer. Feed events via `PublishHackforgerAction` in the existing notifier. - -**Tech Stack:** Go 1.22+, XORM ORM, go-chi router, Go HTML templates, Fomantic UI CSS. - -**Spec:** `docs/superpowers/specs/2026-03-28-phase1-hackathon-design.md` - ---- - -## Chunk 0: Pre-Existing Fixes - -### Task 0: Fix P0 Migration Table Names - -The P0 migration (`v14c_add-hackforger-tables.go`) creates tables with bare names (`hackathon`, `hackathon_track`, etc.) but the model `TableName()` methods return prefixed names (`hackforger_hackathon`, `hackforger_hackathon_track`, etc.). This causes all CRUD to fail. - -**Files:** -- Create: `models/forgejo_migrations/v14d_fix-hackforger-table-names.go` - -- [ ] **Step 1: Write table rename migration** - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package forgejo_migrations - -import "xorm.io/xorm" - -func init() { - registerMigration(&Migration{ - Description: "rename hackforger tables to use hackforger_ prefix", - Upgrade: renameHackforgerTables, - }) -} - -func renameHackforgerTables(x *xorm.Engine) error { - renames := [][2]string{ - {"hackathon", "hackforger_hackathon"}, - {"hackathon_track", "hackforger_hackathon_track"}, - {"hackathon_registration", "hackforger_hackathon_registration"}, - {"hackathon_submission", "hackforger_hackathon_submission"}, - {"hackathon_judge_score", "hackforger_hackathon_judge_score"}, - {"bounty", "hackforger_bounty"}, - {"bounty_reward", "hackforger_bounty_reward"}, - {"bounty_application", "hackforger_bounty_application"}, - {"bounty_winner", "hackforger_bounty_winner"}, - {"grant_round", "hackforger_grant_round"}, - {"grant_project", "hackforger_grant_project"}, - {"credit_account", "hackforger_credit_account"}, - {"credit_transaction", "hackforger_credit_transaction"}, - {"redeem_option", "hackforger_redeem_option"}, - {"redeem_order", "hackforger_redeem_order"}, - {"reputation", "hackforger_reputation"}, - } - for _, r := range renames { - exists, err := x.IsTableExist(r[0]) - if err != nil { - return err - } - if exists { - if _, err := x.Exec("ALTER TABLE `" + r[0] + "` RENAME TO `" + r[1] + "`"); err != nil { - return err - } - } - } - return nil -} -``` - -- [ ] **Step 2: Verify compilation** - -Run: `go build ./models/forgejo_migrations/` -Expected: No errors - -- [ ] **Step 3: Commit** - -```bash -git add models/forgejo_migrations/v14d_fix-hackforger-table-names.go -git commit -m "fix(hackforger): rename P0 tables to use hackforger_ prefix" -``` - ---- - -## Chunk 1: Models Layer - -### Task 1: HackathonJudge Model + Migration - -**Files:** -- Create: `models/hackforger/hackathon_judge.go` -- Create: `models/forgejo_migrations/v14e_add-hackathon-judge-table.go` - -- [ ] **Step 1: Write hackathon_judge.go model** - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package hackforger - -import ( - "context" - "fmt" - - "forgejo.org/models/db" - "forgejo.org/modules/timeutil" - "forgejo.org/modules/util" -) - -// HackathonJudge represents a judge assignment for a hackathon. -type HackathonJudge struct { - ID int64 `xorm:"pk autoincr"` - HackathonID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"` - UserID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"` - CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` -} - -func init() { - db.RegisterModel(new(HackathonJudge)) -} - -func (j *HackathonJudge) TableName() string { - return "hackforger_hackathon_judge" -} - -type ErrDuplicateJudge struct { - HackathonID int64 - UserID int64 -} - -func IsErrDuplicateJudge(err error) bool { - _, ok := err.(ErrDuplicateJudge) - return ok -} - -func (err ErrDuplicateJudge) Error() string { - return fmt.Sprintf("judge already assigned [hackathon_id: %d, user_id: %d]", err.HackathonID, err.UserID) -} - -func (err ErrDuplicateJudge) Unwrap() error { - return util.ErrAlreadyExist -} - -// AddJudge assigns a user as judge for a hackathon. -func AddJudge(ctx context.Context, hackathonID, userID int64) error { - exists, err := db.GetEngine(ctx).Where("hackathon_id = ? AND user_id = ?", hackathonID, userID).Exist(new(HackathonJudge)) - if err != nil { - return err - } - if exists { - return ErrDuplicateJudge{HackathonID: hackathonID, UserID: userID} - } - return db.Insert(ctx, &HackathonJudge{HackathonID: hackathonID, UserID: userID}) -} - -// RemoveJudge removes a judge from a hackathon. -func RemoveJudge(ctx context.Context, hackathonID, userID int64) error { - _, err := db.GetEngine(ctx).Where("hackathon_id = ? AND user_id = ?", hackathonID, userID).Delete(new(HackathonJudge)) - return err -} - -// ListJudges returns all judges for a hackathon. -func ListJudges(ctx context.Context, hackathonID int64) ([]*HackathonJudge, error) { - var judges []*HackathonJudge - err := db.GetEngine(ctx).Where("hackathon_id = ?", hackathonID).Find(&judges) - return judges, err -} - -// IsJudge checks if a user is a judge for a hackathon. -func IsJudge(ctx context.Context, hackathonID, userID int64) (bool, error) { - return db.GetEngine(ctx).Where("hackathon_id = ? AND user_id = ?", hackathonID, userID).Exist(new(HackathonJudge)) -} - -// CountJudges returns the number of judges for a hackathon. -func CountJudges(ctx context.Context, hackathonID int64) (int64, error) { - return db.GetEngine(ctx).Where("hackathon_id = ?", hackathonID).Count(new(HackathonJudge)) -} -``` - -- [ ] **Step 2: Write migration file** - -File: `models/forgejo_migrations/v14e_add-hackathon-judge-table.go` - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package forgejo_migrations - -import "xorm.io/xorm" - -func init() { - registerMigration(&Migration{ - Description: "add hackathon judge assignment table", - Upgrade: addHackathonJudgeTable, - }) -} - -type v14eHackathonJudge struct { - ID int64 `xorm:"pk autoincr"` - HackathonID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"` - UserID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"` - CreatedUnix int64 `xorm:"created"` -} - -func (v14eHackathonJudge) TableName() string { return "hackforger_hackathon_judge" } - -func addHackathonJudgeTable(x *xorm.Engine) error { - return x.Sync(new(v14eHackathonJudge)) -} -``` - -- [ ] **Step 3: Verify compilation** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-hackathon && go build ./models/hackforger/ ./models/forgejo_migrations/` -Expected: No errors - -- [ ] **Step 4: Commit** - -```bash -git add models/hackforger/hackathon_judge.go models/forgejo_migrations/v14e_add-hackathon-judge-table.go -git commit -m "feat(hackforger): add HackathonJudge model and migration" -``` - ---- - -### Task 2: Enhance Hackathon Model (Pagination + Search + StatusUpdate) - -**Files:** -- Modify: `models/hackforger/hackathon.go` - -- [ ] **Step 1: Add Keyword to ListHackathonsOptions and switch to FindAndCount** - -In `models/hackforger/hackathon.go`, modify: - -```go -// ListHackathonsOptions holds options for listing hackathons. -type ListHackathonsOptions struct { - db.ListOptions - OrgID int64 - Status *HackathonStatus - Keyword string -} - -func (opts ListHackathonsOptions) ToConds() builder.Cond { - cond := builder.NewCond() - if opts.OrgID != 0 { - cond = cond.And(builder.Eq{"hackforger_hackathon.org_id": opts.OrgID}) - } - if opts.Status != nil { - cond = cond.And(builder.Eq{"hackforger_hackathon.status": *opts.Status}) - } - if opts.Keyword != "" { - cond = cond.And(builder.Or( - builder.Like{"hackforger_hackathon.name", opts.Keyword}, - builder.Like{"hackforger_hackathon.description", opts.Keyword}, - )) - } - return cond -} - -// ListHackathons returns hackathons matching the given options with total count. -func ListHackathons(ctx context.Context, opts ListHackathonsOptions) ([]*Hackathon, int64, error) { - hackathons, count, err := db.FindAndCount[Hackathon](ctx, opts) - return hackathons, count, err -} -``` - -- [ ] **Step 2: Add UpdateHackathonStatus** - -Append to `models/hackforger/hackathon.go`: - -```go -// UpdateHackathonStatus updates only the status field of a hackathon. -func UpdateHackathonStatus(ctx context.Context, id int64, status HackathonStatus) error { - _, err := db.GetEngine(ctx).ID(id).Cols("status").Update(&Hackathon{Status: status}) - return err -} -``` - -- [ ] **Step 3: Verify compilation** - -Run: `go build ./models/hackforger/` -Expected: No errors - -- [ ] **Step 4: Commit** - -```bash -git add models/hackforger/hackathon.go -git commit -m "feat(hackforger): add keyword search, pagination, and status update to Hackathon model" -``` - ---- - -### Task 3: HackathonTrack CRUD Functions - -**Files:** -- Modify: `models/hackforger/hackathon_track.go` - -- [ ] **Step 1: Add CRUD functions** - -Append to `models/hackforger/hackathon_track.go`: - -```go -import ( - "context" - "fmt" - - "forgejo.org/models/db" - "forgejo.org/modules/timeutil" - "forgejo.org/modules/util" -) - -// ErrTrackNotExist represents a "TrackNotExist" kind of error. -type ErrTrackNotExist struct { - ID int64 -} - -func IsErrTrackNotExist(err error) bool { - _, ok := err.(ErrTrackNotExist) - return ok -} - -func (err ErrTrackNotExist) Error() string { - return fmt.Sprintf("hackathon track does not exist [id: %d]", err.ID) -} - -func (err ErrTrackNotExist) Unwrap() error { - return util.ErrNotExist -} - -// GetTrackByID returns a track by its ID. -func GetTrackByID(ctx context.Context, id int64) (*HackathonTrack, error) { - t, exists, err := db.GetByID[HackathonTrack](ctx, id) - if err != nil { - return nil, err - } - if !exists { - return nil, ErrTrackNotExist{ID: id} - } - return t, nil -} - -// ListTracksByHackathon returns all tracks for a hackathon. -func ListTracksByHackathon(ctx context.Context, hackathonID int64) ([]*HackathonTrack, error) { - var tracks []*HackathonTrack - err := db.GetEngine(ctx).Where("hackathon_id = ?", hackathonID).OrderBy("id ASC").Find(&tracks) - return tracks, err -} - -// CreateTrack creates a new track. -func CreateTrack(ctx context.Context, t *HackathonTrack) error { - return db.Insert(ctx, t) -} - -// UpdateTrack updates an existing track. -func UpdateTrack(ctx context.Context, t *HackathonTrack) error { - _, err := db.GetEngine(ctx).ID(t.ID).AllCols().Update(t) - return err -} - -// DeleteTrack deletes a track by ID. -func DeleteTrack(ctx context.Context, id int64) error { - _, err := db.GetEngine(ctx).ID(id).Delete(new(HackathonTrack)) - return err -} - -// CountTracksByHackathon returns the number of tracks for a hackathon. -func CountTracksByHackathon(ctx context.Context, hackathonID int64) (int64, error) { - return db.GetEngine(ctx).Where("hackathon_id = ?", hackathonID).Count(new(HackathonTrack)) -} -``` - -Note: The existing `HackathonTrack` struct has no `SortOrder` field. The `OrderBy("sort_order")` references the spec's `SortOrder` column from the design doc. Check the struct — if `SortOrder` is missing, order by `id ASC` only. Looking at the struct (Task 3, Step 1 note): the existing struct does NOT have SortOrder. Use `OrderBy("id ASC")` instead. - -- [ ] **Step 2: Verify compilation** - -Run: `go build ./models/hackforger/` -Expected: No errors - -- [ ] **Step 3: Commit** - -```bash -git add models/hackforger/hackathon_track.go -git commit -m "feat(hackforger): add HackathonTrack CRUD functions" -``` - ---- - -### Task 4: HackathonRegistration CRUD Functions - -**Files:** -- Modify: `models/hackforger/hackathon_registration.go` - -- [ ] **Step 1: Add CRUD functions** - -Append to `models/hackforger/hackathon_registration.go`: - -```go -import ( - "context" - // existing imports... -) - -// ErrDuplicateRegistration represents a duplicate registration error. -type ErrDuplicateRegistration struct { - HackathonID int64 - UserID int64 -} - -func IsErrDuplicateRegistration(err error) bool { - _, ok := err.(ErrDuplicateRegistration) - return ok -} - -func (err ErrDuplicateRegistration) Error() string { - return fmt.Sprintf("user already registered [hackathon_id: %d, user_id: %d]", err.HackathonID, err.UserID) -} - -func (err ErrDuplicateRegistration) Unwrap() error { - return util.ErrAlreadyExist -} - -// GetRegistration returns a registration for a user in a hackathon. -func GetRegistration(ctx context.Context, hackathonID, userID int64) (*HackathonRegistration, error) { - r := &HackathonRegistration{} - has, err := db.GetEngine(ctx).Where("hackathon_id = ? AND user_id = ?", hackathonID, userID).Get(r) - if err != nil { - return nil, err - } - if !has { - return nil, ErrRegistrationNotExist{ID: 0} - } - return r, nil -} - -// GetRegistrationByID returns a registration by its ID. -func GetRegistrationByID(ctx context.Context, id int64) (*HackathonRegistration, error) { - r, exists, err := db.GetByID[HackathonRegistration](ctx, id) - if err != nil { - return nil, err - } - if !exists { - return nil, ErrRegistrationNotExist{ID: id} - } - return r, nil -} - -// ListRegistrationsOptions holds options for listing registrations. -type ListRegistrationsOptions struct { - db.ListOptions - HackathonID int64 - Status *RegistrationStatus -} - -func (opts ListRegistrationsOptions) ToConds() builder.Cond { - cond := builder.NewCond() - if opts.HackathonID != 0 { - cond = cond.And(builder.Eq{"hackforger_hackathon_registration.hackathon_id": opts.HackathonID}) - } - if opts.Status != nil { - cond = cond.And(builder.Eq{"hackforger_hackathon_registration.status": *opts.Status}) - } - return cond -} - -// ListRegistrations returns registrations matching the given options with total count. -func ListRegistrations(ctx context.Context, opts ListRegistrationsOptions) ([]*HackathonRegistration, int64, error) { - return db.FindAndCount[HackathonRegistration](ctx, opts) -} - -// CreateRegistration creates a new registration. -func CreateRegistration(ctx context.Context, r *HackathonRegistration) error { - exists, err := db.GetEngine(ctx).Where("hackathon_id = ? AND user_id = ?", r.HackathonID, r.UserID).Exist(new(HackathonRegistration)) - if err != nil { - return err - } - if exists { - return ErrDuplicateRegistration{HackathonID: r.HackathonID, UserID: r.UserID} - } - return db.Insert(ctx, r) -} - -// UpdateRegistrationStatus updates the status of a registration. -func UpdateRegistrationStatus(ctx context.Context, id int64, status RegistrationStatus) error { - _, err := db.GetEngine(ctx).ID(id).Cols("status").Update(&HackathonRegistration{Status: status}) - return err -} - -// CountRegistrations returns the total number of registrations for a hackathon. -func CountRegistrations(ctx context.Context, hackathonID int64) (int64, error) { - return db.GetEngine(ctx).Where("hackathon_id = ?", hackathonID).Count(new(HackathonRegistration)) -} -``` - -Note: Needs `"xorm.io/builder"` and `"context"` in imports. Adjust the existing import block to include these. - -- [ ] **Step 2: Verify compilation** - -Run: `go build ./models/hackforger/` -Expected: No errors - -- [ ] **Step 3: Commit** - -```bash -git add models/hackforger/hackathon_registration.go -git commit -m "feat(hackforger): add HackathonRegistration CRUD functions" -``` - ---- - -### Task 5: HackathonSubmission CRUD Functions - -**Files:** -- Modify: `models/hackforger/hackathon_submission.go` - -- [ ] **Step 1: Add CRUD functions** - -Append to `models/hackforger/hackathon_submission.go`: - -```go -import ( - "context" - // existing imports... -) - -// ListSubmissionsOptions holds options for listing submissions. -type ListSubmissionsOptions struct { - db.ListOptions - HackathonID int64 - TrackID int64 - Status *SubmissionStatus -} - -func (opts ListSubmissionsOptions) ToConds() builder.Cond { - cond := builder.NewCond() - if opts.HackathonID != 0 { - cond = cond.And(builder.Eq{"hackforger_hackathon_submission.hackathon_id": opts.HackathonID}) - } - if opts.TrackID != 0 { - cond = cond.And(builder.Eq{"hackforger_hackathon_submission.track_id": opts.TrackID}) - } - if opts.Status != nil { - cond = cond.And(builder.Eq{"hackforger_hackathon_submission.status": *opts.Status}) - } - return cond -} - -// GetSubmissionByID returns a submission by its ID. -func GetSubmissionByID(ctx context.Context, id int64) (*HackathonSubmission, error) { - s, exists, err := db.GetByID[HackathonSubmission](ctx, id) - if err != nil { - return nil, err - } - if !exists { - return nil, ErrSubmissionNotExist{ID: id} - } - return s, nil -} - -// ListSubmissions returns submissions matching the given options with total count. -func ListSubmissions(ctx context.Context, opts ListSubmissionsOptions) ([]*HackathonSubmission, int64, error) { - return db.FindAndCount[HackathonSubmission](ctx, opts) -} - -// CreateSubmission creates a new submission. -func CreateSubmission(ctx context.Context, s *HackathonSubmission) error { - return db.Insert(ctx, s) -} - -// UpdateSubmission updates an existing submission. -func UpdateSubmission(ctx context.Context, s *HackathonSubmission) error { - _, err := db.GetEngine(ctx).ID(s.ID).AllCols().Update(s) - return err -} - -// CountSubmissions returns the number of submissions for a hackathon. -func CountSubmissions(ctx context.Context, hackathonID int64) (int64, error) { - return db.GetEngine(ctx).Where("hackathon_id = ?", hackathonID).Count(new(HackathonSubmission)) -} - -// SubmissionRanking holds a submission ID and its computed rank. -type SubmissionRanking struct { - SubmissionID int64 - TotalScore float64 - Rank int -} - -// UpdateSubmissionRanks batch-updates TotalScore and Rank for submissions. -func UpdateSubmissionRanks(ctx context.Context, rankings []SubmissionRanking) error { - for _, r := range rankings { - if _, err := db.GetEngine(ctx).ID(r.SubmissionID). - Cols("total_score", "rank"). - Update(&HackathonSubmission{TotalScore: r.TotalScore, Rank: r.Rank}); err != nil { - return err - } - } - return nil -} -``` - -Note: Needs `"xorm.io/builder"` and `"context"` in imports. - -- [ ] **Step 2: Verify compilation** - -Run: `go build ./models/hackforger/` -Expected: No errors - -- [ ] **Step 3: Commit** - -```bash -git add models/hackforger/hackathon_submission.go -git commit -m "feat(hackforger): add HackathonSubmission CRUD functions" -``` - ---- - -### Task 6: HackathonJudgeScore CRUD Functions - -**Files:** -- Modify: `models/hackforger/hackathon_judge_score.go` - -- [ ] **Step 1: Add CRUD functions** - -Append to `models/hackforger/hackathon_judge_score.go`: - -```go -import ( - "context" - // existing imports... -) - -// GetScore returns a judge's score for a submission. -func GetScore(ctx context.Context, judgeID, submissionID int64) (*HackathonJudgeScore, error) { - s := &HackathonJudgeScore{} - has, err := db.GetEngine(ctx).Where("judge_id = ? AND submission_id = ?", judgeID, submissionID).Get(s) - if err != nil { - return nil, err - } - if !has { - return nil, nil - } - return s, nil -} - -// ListScoresBySubmission returns all scores for a submission. -func ListScoresBySubmission(ctx context.Context, submissionID int64) ([]*HackathonJudgeScore, error) { - var scores []*HackathonJudgeScore - err := db.GetEngine(ctx).Where("submission_id = ?", submissionID).Find(&scores) - return scores, err -} - -// CreateScore creates a new score record. -func CreateScore(ctx context.Context, s *HackathonJudgeScore) error { - exists, err := db.GetEngine(ctx).Where("judge_id = ? AND submission_id = ?", s.JudgeID, s.SubmissionID).Exist(new(HackathonJudgeScore)) - if err != nil { - return err - } - if exists { - return ErrDuplicateScore{JudgeID: s.JudgeID, SubmissionID: s.SubmissionID} - } - return db.Insert(ctx, s) -} - -// UpdateScore updates an existing score. -func UpdateScore(ctx context.Context, s *HackathonJudgeScore) error { - _, err := db.GetEngine(ctx).ID(s.ID).Cols("score", "comment").Update(s) - return err -} - -// HasAllJudgesScored checks if every assigned judge has scored a given submission. -func HasAllJudgesScored(ctx context.Context, hackathonID, submissionID int64) (bool, error) { - judgeCount, err := db.GetEngine(ctx).Where("hackathon_id = ?", hackathonID).Count(new(HackathonJudge)) - if err != nil { - return false, err - } - if judgeCount == 0 { - return false, nil - } - scoreCount, err := db.GetEngine(ctx).Where("submission_id = ?", submissionID).Count(new(HackathonJudgeScore)) - if err != nil { - return false, err - } - return scoreCount >= judgeCount, nil -} -``` - -- [ ] **Step 2: Verify compilation** - -Run: `go build ./models/hackforger/` -Expected: No errors - -- [ ] **Step 3: Commit** - -```bash -git add models/hackforger/hackathon_judge_score.go -git commit -m "feat(hackforger): add HackathonJudgeScore CRUD functions" -``` - ---- - -### Task 7: Model Unit Tests - -**Files:** -- Create: `models/hackforger/main_test.go` -- Create: `models/hackforger/hackathon_test.go` - -- [ ] **Step 1: Create test bootstrap** - -File: `models/hackforger/main_test.go` - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package hackforger_test - -import ( - "testing" - - "forgejo.org/models/unittest" - - _ "forgejo.org/modules/testimport" -) - -func TestMain(m *testing.M) { - unittest.MainTest(m) -} -``` - -- [ ] **Step 2: Write hackathon model tests** - -File: `models/hackforger/hackathon_test.go` - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package hackforger_test - -import ( - "testing" - - "forgejo.org/models/db" - hackforger_model "forgejo.org/models/hackforger" - "forgejo.org/models/unittest" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestCreateAndGetHackathon(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - h := &hackforger_model.Hackathon{ - OrgID: 1, - OwnerID: 1, - Name: "Test Hackathon", - Slug: "test-hackathon", - Status: hackforger_model.HackathonStatusDraft, - } - require.NoError(t, hackforger_model.CreateHackathon(db.DefaultContext, h)) - assert.Greater(t, h.ID, int64(0)) - - got, err := hackforger_model.GetHackathonByID(db.DefaultContext, h.ID) - require.NoError(t, err) - assert.Equal(t, "Test Hackathon", got.Name) - - gotBySlug, err := hackforger_model.GetHackathonBySlug(db.DefaultContext, "test-hackathon") - require.NoError(t, err) - assert.Equal(t, h.ID, gotBySlug.ID) -} - -func TestListHackathonsWithKeyword(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Create two hackathons - h1 := &hackforger_model.Hackathon{OrgID: 1, OwnerID: 1, Name: "Spring Hack", Slug: "spring-hack"} - h2 := &hackforger_model.Hackathon{OrgID: 1, OwnerID: 1, Name: "Winter Jam", Slug: "winter-jam"} - require.NoError(t, hackforger_model.CreateHackathon(db.DefaultContext, h1)) - require.NoError(t, hackforger_model.CreateHackathon(db.DefaultContext, h2)) - - // Search by keyword - results, count, err := hackforger_model.ListHackathons(db.DefaultContext, hackforger_model.ListHackathonsOptions{ - Keyword: "Spring", - }) - require.NoError(t, err) - assert.Equal(t, int64(1), count) - assert.Equal(t, "Spring Hack", results[0].Name) -} - -func TestUpdateHackathonStatus(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - h := &hackforger_model.Hackathon{OrgID: 1, OwnerID: 1, Name: "Status Test", Slug: "status-test"} - require.NoError(t, hackforger_model.CreateHackathon(db.DefaultContext, h)) - - require.NoError(t, hackforger_model.UpdateHackathonStatus(db.DefaultContext, h.ID, hackforger_model.HackathonStatusOpen)) - - got, err := hackforger_model.GetHackathonByID(db.DefaultContext, h.ID) - require.NoError(t, err) - assert.Equal(t, hackforger_model.HackathonStatusOpen, got.Status) -} - -func TestDeleteHackathonOnlyDraft(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - h := &hackforger_model.Hackathon{OrgID: 1, OwnerID: 1, Name: "Del Test", Slug: "del-test", Status: hackforger_model.HackathonStatusOpen} - require.NoError(t, hackforger_model.CreateHackathon(db.DefaultContext, h)) - - err := hackforger_model.DeleteHackathon(db.DefaultContext, h.ID) - assert.Error(t, err) // Should fail — not Draft -} - -func TestHackathonJudgeCRUD(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - hackathonID := int64(999) - - // Add judge - require.NoError(t, hackforger_model.AddJudge(db.DefaultContext, hackathonID, 1)) - - // Duplicate should error - err := hackforger_model.AddJudge(db.DefaultContext, hackathonID, 1) - assert.True(t, hackforger_model.IsErrDuplicateJudge(err)) - - // IsJudge - isJudge, err := hackforger_model.IsJudge(db.DefaultContext, hackathonID, 1) - require.NoError(t, err) - assert.True(t, isJudge) - - // List judges - judges, err := hackforger_model.ListJudges(db.DefaultContext, hackathonID) - require.NoError(t, err) - assert.Len(t, judges, 1) - - // Count - count, err := hackforger_model.CountJudges(db.DefaultContext, hackathonID) - require.NoError(t, err) - assert.Equal(t, int64(1), count) - - // Remove - require.NoError(t, hackforger_model.RemoveJudge(db.DefaultContext, hackathonID, 1)) - count, err = hackforger_model.CountJudges(db.DefaultContext, hackathonID) - require.NoError(t, err) - assert.Equal(t, int64(0), count) -} - -func TestHackathonTrackCRUD(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - t1 := &hackforger_model.HackathonTrack{HackathonID: 1, Name: "AI Track"} - require.NoError(t, hackforger_model.CreateTrack(db.DefaultContext, t1)) - assert.Greater(t, t1.ID, int64(0)) - - got, err := hackforger_model.GetTrackByID(db.DefaultContext, t1.ID) - require.NoError(t, err) - assert.Equal(t, "AI Track", got.Name) - - tracks, err := hackforger_model.ListTracksByHackathon(db.DefaultContext, 1) - require.NoError(t, err) - assert.Len(t, tracks, 1) - - count, err := hackforger_model.CountTracksByHackathon(db.DefaultContext, 1) - require.NoError(t, err) - assert.Equal(t, int64(1), count) - - require.NoError(t, hackforger_model.DeleteTrack(db.DefaultContext, t1.ID)) - count, err = hackforger_model.CountTracksByHackathon(db.DefaultContext, 1) - require.NoError(t, err) - assert.Equal(t, int64(0), count) -} - -func TestRegistrationCRUD(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - r := &hackforger_model.HackathonRegistration{HackathonID: 1, UserID: 2, TeamName: "Team Alpha"} - require.NoError(t, hackforger_model.CreateRegistration(db.DefaultContext, r)) - - // Duplicate - err := hackforger_model.CreateRegistration(db.DefaultContext, &hackforger_model.HackathonRegistration{HackathonID: 1, UserID: 2, TeamName: "Dup"}) - assert.True(t, hackforger_model.IsErrDuplicateRegistration(err)) - - // Get - got, err := hackforger_model.GetRegistration(db.DefaultContext, 1, 2) - require.NoError(t, err) - assert.Equal(t, "Team Alpha", got.TeamName) - - // Update status - require.NoError(t, hackforger_model.UpdateRegistrationStatus(db.DefaultContext, r.ID, hackforger_model.RegistrationStatusApproved)) - got, err = hackforger_model.GetRegistrationByID(db.DefaultContext, r.ID) - require.NoError(t, err) - assert.Equal(t, hackforger_model.RegistrationStatusApproved, got.Status) -} - -func TestSubmissionCRUD(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - s := &hackforger_model.HackathonSubmission{ - HackathonID: 1, - RegistrationID: 1, - UserID: 2, - Title: "My Project", - DemoURL: "https://example.com/demo", - } - require.NoError(t, hackforger_model.CreateSubmission(db.DefaultContext, s)) - assert.Greater(t, s.ID, int64(0)) - - got, err := hackforger_model.GetSubmissionByID(db.DefaultContext, s.ID) - require.NoError(t, err) - assert.Equal(t, "My Project", got.Title) - - count, err := hackforger_model.CountSubmissions(db.DefaultContext, 1) - require.NoError(t, err) - assert.Equal(t, int64(1), count) -} - -func TestScoreCRUD(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - sc := &hackforger_model.HackathonJudgeScore{ - HackathonID: 1, - SubmissionID: 1, - JudgeID: 10, - Score: 8.5, - Comment: "Good work", - } - require.NoError(t, hackforger_model.CreateScore(db.DefaultContext, sc)) - - // Duplicate - err := hackforger_model.CreateScore(db.DefaultContext, &hackforger_model.HackathonJudgeScore{ - HackathonID: 1, SubmissionID: 1, JudgeID: 10, Score: 9, - }) - assert.True(t, hackforger_model.IsErrDuplicateScore(err)) - - // Get - got, err := hackforger_model.GetScore(db.DefaultContext, 10, 1) - require.NoError(t, err) - assert.Equal(t, 8.5, got.Score) - - // List - scores, err := hackforger_model.ListScoresBySubmission(db.DefaultContext, 1) - require.NoError(t, err) - assert.Len(t, scores, 1) -} -``` - -- [ ] **Step 3: Run tests** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-hackathon && TAGS="bindata sqlite sqlite_unlock_notify" go test ./models/hackforger/... -v -count=1` -Expected: All tests PASS - -- [ ] **Step 4: Commit** - -```bash -git add models/hackforger/main_test.go models/hackforger/hackathon_test.go -git commit -m "test(hackforger): add Hackathon model unit tests" -``` - ---- - -## Chunk 2: Services Layer - -### Task 8: Hackathon State Machine Service - -**Files:** -- Create: `services/hackforger/hackathon.go` - -- [ ] **Step 1: Write state machine service** - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package hackforger - -import ( - "context" - "fmt" - - "forgejo.org/models/db" - hackforger_model "forgejo.org/models/hackforger" -) - -// PublishHackathon transitions a hackathon from Draft → Open. -// Requires: at least 1 track. -func PublishHackathon(ctx context.Context, doerID int64, h *hackforger_model.Hackathon) error { - if h.Status != hackforger_model.HackathonStatusDraft { - return fmt.Errorf("hackathon must be in Draft status to publish [id: %d, status: %d]", h.ID, h.Status) - } - trackCount, err := hackforger_model.CountTracksByHackathon(ctx, h.ID) - if err != nil { - return err - } - if trackCount == 0 { - return fmt.Errorf("hackathon must have at least 1 track to publish [id: %d]", h.ID) - } - - if err := hackforger_model.UpdateHackathonStatus(ctx, h.ID, hackforger_model.HackathonStatusOpen); err != nil { - return err - } - - publishPhaseChange(ctx, doerID, h, hackforger_model.HackathonStatusDraft, hackforger_model.HackathonStatusOpen) - return nil -} - -// StartHacking transitions a hackathon from Open → Hacking. -func StartHacking(ctx context.Context, doerID int64, h *hackforger_model.Hackathon) error { - if h.Status != hackforger_model.HackathonStatusOpen { - return fmt.Errorf("hackathon must be in Open status to start hacking [id: %d, status: %d]", h.ID, h.Status) - } - - if err := hackforger_model.UpdateHackathonStatus(ctx, h.ID, hackforger_model.HackathonStatusHacking); err != nil { - return err - } - - publishPhaseChange(ctx, doerID, h, hackforger_model.HackathonStatusOpen, hackforger_model.HackathonStatusHacking) - return nil -} - -// StartJudging transitions a hackathon from Hacking → Judging. -// Requires: at least 1 submission. -func StartJudging(ctx context.Context, doerID int64, h *hackforger_model.Hackathon) error { - if h.Status != hackforger_model.HackathonStatusHacking { - return fmt.Errorf("hackathon must be in Hacking status to start judging [id: %d, status: %d]", h.ID, h.Status) - } - subCount, err := hackforger_model.CountSubmissions(ctx, h.ID) - if err != nil { - return err - } - if subCount == 0 { - return fmt.Errorf("hackathon must have at least 1 submission to start judging [id: %d]", h.ID) - } - - if err := hackforger_model.UpdateHackathonStatus(ctx, h.ID, hackforger_model.HackathonStatusJudging); err != nil { - return err - } - - publishPhaseChange(ctx, doerID, h, hackforger_model.HackathonStatusHacking, hackforger_model.HackathonStatusJudging) - return nil -} - -// FinalizeHackathon transitions a hackathon from Judging → Finished. -// Calculates ranks and publishes finalized event. -func FinalizeHackathon(ctx context.Context, doerID int64, h *hackforger_model.Hackathon) error { - if h.Status != hackforger_model.HackathonStatusJudging { - return fmt.Errorf("hackathon must be in Judging status to finalize [id: %d, status: %d]", h.ID, h.Status) - } - - if err := CalculateRanks(ctx, h.ID); err != nil { - return err - } - - if err := hackforger_model.UpdateHackathonStatus(ctx, h.ID, hackforger_model.HackathonStatusFinished); err != nil { - return err - } - - // Finalized event (global audience) - _ = PublishHackforgerAction(ctx, &HackforgerActionOpts{ - ActUserID: doerID, - OpType: hackforger_model.ActionHackathonFinalized, - AudienceType: AudienceGlobal, - Content: hackforger_model.HackforgerPhaseContent{ - HackforgerActionContent: hackforger_model.HackforgerActionContent{ - EntityType: "hackathon", - EntityID: h.ID, - EntityName: h.Name, - EntitySlug: h.Slug, - }, - OldStatus: "judging", - NewStatus: "finished", - }, - }) - return nil -} - -// CancelHackathon transitions a hackathon to Cancelled. -// Any non-Finished status can be cancelled. -func CancelHackathon(ctx context.Context, doerID int64, h *hackforger_model.Hackathon) error { - if h.Status == hackforger_model.HackathonStatusFinished { - return fmt.Errorf("cannot cancel a finished hackathon [id: %d]", h.ID) - } - if h.Status == hackforger_model.HackathonStatusCancelled { - return fmt.Errorf("hackathon is already cancelled [id: %d]", h.ID) - } - - oldStatus := h.Status - if err := hackforger_model.UpdateHackathonStatus(ctx, h.ID, hackforger_model.HackathonStatusCancelled); err != nil { - return err - } - - publishPhaseChange(ctx, doerID, h, oldStatus, hackforger_model.HackathonStatusCancelled) - return nil -} - -// CreateHackathon creates a hackathon and publishes a creation event. -func CreateHackathon(ctx context.Context, doerID int64, h *hackforger_model.Hackathon) error { - if err := hackforger_model.CreateHackathon(ctx, h); err != nil { - return err - } - - _ = PublishHackforgerAction(ctx, &HackforgerActionOpts{ - ActUserID: doerID, - OpType: hackforger_model.ActionHackathonCreated, - AudienceType: AudienceGlobal, - Content: hackforger_model.HackforgerActionContent{ - EntityType: "hackathon", - EntityID: h.ID, - EntityName: h.Name, - EntitySlug: h.Slug, - }, - }) - return nil -} - -// hackathonStatusName maps status to string for feed content. -var hackathonStatusName = map[hackforger_model.HackathonStatus]string{ - hackforger_model.HackathonStatusDraft: "draft", - hackforger_model.HackathonStatusOpen: "open", - hackforger_model.HackathonStatusHacking: "hacking", - hackforger_model.HackathonStatusJudging: "judging", - hackforger_model.HackathonStatusFinished: "finished", - hackforger_model.HackathonStatusCancelled: "cancelled", -} - -// publishPhaseChange publishes a HackathonPhaseChanged feed event. -func publishPhaseChange(ctx context.Context, doerID int64, h *hackforger_model.Hackathon, oldStatus, newStatus hackforger_model.HackathonStatus) { - _ = PublishHackforgerAction(ctx, &HackforgerActionOpts{ - ActUserID: doerID, - OpType: hackforger_model.ActionHackathonPhaseChanged, - AudienceType: AudienceOrgMembers, - OrgID: h.OrgID, - Content: hackforger_model.HackforgerPhaseContent{ - HackforgerActionContent: hackforger_model.HackforgerActionContent{ - EntityType: "hackathon", - EntityID: h.ID, - EntityName: h.Name, - EntitySlug: h.Slug, - }, - OldStatus: hackathonStatusName[oldStatus], - NewStatus: hackathonStatusName[newStatus], - }, - }) -} - -// unused import guard -var _ = db.DefaultContext -``` - -- [ ] **Step 2: Verify compilation** - -Run: `go build ./services/hackforger/` -Expected: No errors - -- [ ] **Step 3: Commit** - -```bash -git add services/hackforger/hackathon.go -git commit -m "feat(hackforger): add Hackathon state machine service" -``` - ---- - -### Task 9: Hackathon Judge Service (Scoring + Ranks) - -**Files:** -- Create: `services/hackforger/hackathon_judge.go` - -- [ ] **Step 1: Write judge service** - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package hackforger - -import ( - "context" - "fmt" - "sort" - - hackforger_model "forgejo.org/models/hackforger" -) - -// SubmitScore validates and records a judge's score for a submission. -func SubmitScore(ctx context.Context, judgeID, submissionID int64, score float64, comment string) error { - // Get submission to find hackathon - sub, err := hackforger_model.GetSubmissionByID(ctx, submissionID) - if err != nil { - return err - } - - // Verify hackathon is in Judging phase - h, err := hackforger_model.GetHackathonByID(ctx, sub.HackathonID) - if err != nil { - return err - } - if h.Status != hackforger_model.HackathonStatusJudging { - return fmt.Errorf("hackathon must be in Judging status to score [id: %d, status: %d]", h.ID, h.Status) - } - - // Verify user is a judge - isJudge, err := hackforger_model.IsJudge(ctx, h.ID, judgeID) - if err != nil { - return err - } - if !isJudge { - return fmt.Errorf("user is not a judge for this hackathon [user_id: %d, hackathon_id: %d]", judgeID, h.ID) - } - - // Validate score range (0-10) - if score < 0 || score > 10 { - return fmt.Errorf("score must be between 0 and 10 [got: %f]", score) - } - - // Create or update score - existing, err := hackforger_model.GetScore(ctx, judgeID, submissionID) - if err != nil { - return err - } - if existing != nil { - existing.Score = score - existing.Comment = comment - if err := hackforger_model.UpdateScore(ctx, existing); err != nil { - return err - } - } else { - s := &hackforger_model.HackathonJudgeScore{ - HackathonID: h.ID, - SubmissionID: submissionID, - JudgeID: judgeID, - Score: score, - Comment: comment, - } - if err := hackforger_model.CreateScore(ctx, s); err != nil { - return err - } - } - - // Publish scored event - _ = PublishHackforgerAction(ctx, &HackforgerActionOpts{ - ActUserID: judgeID, - OpType: hackforger_model.ActionHackathonScored, - AudienceType: AudienceFollowers, - Content: hackforger_model.HackforgerActionContent{ - EntityType: "hackathon", - EntityID: h.ID, - EntityName: h.Name, - EntitySlug: h.Slug, - Extra: map[string]any{"submission_id": submissionID}, - }, - }) - - return nil -} - -// CalculateRanks computes average scores and assigns ranks for all submissions in a hackathon. -func CalculateRanks(ctx context.Context, hackathonID int64) error { - subs, _, err := hackforger_model.ListSubmissions(ctx, hackforger_model.ListSubmissionsOptions{ - HackathonID: hackathonID, - }) - if err != nil { - return err - } - - type scored struct { - id int64 - avg float64 - } - var scoredSubs []scored - - for _, sub := range subs { - scores, err := hackforger_model.ListScoresBySubmission(ctx, sub.ID) - if err != nil { - return err - } - if len(scores) == 0 { - scoredSubs = append(scoredSubs, scored{id: sub.ID, avg: 0}) - continue - } - var total float64 - for _, s := range scores { - total += s.Score - } - scoredSubs = append(scoredSubs, scored{id: sub.ID, avg: total / float64(len(scores))}) - } - - // Sort descending by average score - sort.Slice(scoredSubs, func(i, j int) bool { - return scoredSubs[i].avg > scoredSubs[j].avg - }) - - // Build rankings - rankings := make([]hackforger_model.SubmissionRanking, len(scoredSubs)) - for i, s := range scoredSubs { - rankings[i] = hackforger_model.SubmissionRanking{ - SubmissionID: s.id, - TotalScore: s.avg, - Rank: i + 1, - } - } - - return hackforger_model.UpdateSubmissionRanks(ctx, rankings) -} -``` - -- [ ] **Step 2: Verify compilation** - -Run: `go build ./services/hackforger/` -Expected: No errors - -- [ ] **Step 3: Commit** - -```bash -git add services/hackforger/hackathon_judge.go -git commit -m "feat(hackforger): add judge scoring and rank calculation service" -``` - ---- - -### Task 10: Extend Notifier Audience Resolution - -**Files:** -- Modify: `services/hackforger/notifier.go` - -- [ ] **Step 1: Implement full audience resolution in PublishHackforgerAction** - -Replace the `// Phase 1 TODO` block (lines 96-99) with actual audience resolution: - -```go - // Phase 1: resolve audience based on opts.AudienceType - switch opts.AudienceType { - case AudienceFollowers: - // Query followers of the actor - var followerIDs []int64 - err := db.GetEngine(ctx).Table("user_follow"). - Where("follow_id = ?", opts.ActUserID). - Cols("user_id").Find(&followerIDs) - if err != nil { - log.Error("PublishHackforgerAction (followers query): %v", err) - break - } - for _, uid := range followerIDs { - if uid == opts.ActUserID { - continue // skip actor, already inserted - } - fa := &activities_model.Action{ - ActUserID: opts.ActUserID, - UserID: uid, - OpType: opts.OpType, - RepoID: opts.RepoID, - Content: contentStr, - CreatedUnix: now, - } - if _, err := db.GetEngine(ctx).Insert(fa); err != nil { - log.Error("PublishHackforgerAction (follower %d): %v", uid, err) - } - } - - case AudienceOrgMembers: - if opts.OrgID == 0 { - break - } - var memberIDs []int64 - err := db.GetEngine(ctx).Table("org_user"). - Where("org_id = ?", opts.OrgID). - Cols("uid").Find(&memberIDs) - if err != nil { - log.Error("PublishHackforgerAction (org members query): %v", err) - break - } - for _, uid := range memberIDs { - if uid == opts.ActUserID { - continue - } - ma := &activities_model.Action{ - ActUserID: opts.ActUserID, - UserID: uid, - OpType: opts.OpType, - RepoID: opts.RepoID, - Content: contentStr, - CreatedUnix: now, - } - if _, err := db.GetEngine(ctx).Insert(ma); err != nil { - log.Error("PublishHackforgerAction (org member %d): %v", uid, err) - } - } - - case AudienceRepoWatchers: - if opts.RepoID == 0 { - break - } - var watcherIDs []int64 - err := db.GetEngine(ctx).Table("watch"). - Where("repo_id = ? AND mode != 2", opts.RepoID). // mode 2 = don't watch - Cols("user_id").Find(&watcherIDs) - if err != nil { - log.Error("PublishHackforgerAction (watchers query): %v", err) - break - } - for _, uid := range watcherIDs { - if uid == opts.ActUserID { - continue - } - wa := &activities_model.Action{ - ActUserID: opts.ActUserID, - UserID: uid, - OpType: opts.OpType, - RepoID: opts.RepoID, - Content: contentStr, - CreatedUnix: now, - } - if _, err := db.GetEngine(ctx).Insert(wa); err != nil { - log.Error("PublishHackforgerAction (watcher %d): %v", uid, err) - } - } - } -``` - -- [ ] **Step 2: Verify compilation** - -Run: `go build ./services/hackforger/` -Expected: No errors - -- [ ] **Step 3: Commit** - -```bash -git add services/hackforger/notifier.go -git commit -m "feat(hackforger): implement full audience resolution in PublishHackforgerAction" -``` - ---- - -### Task 11: Service Tests - -**Files:** -- Create: `services/hackforger/main_test.go` -- Create: `services/hackforger/hackathon_test.go` - -- [ ] **Step 1: Create test bootstrap** - -File: `services/hackforger/main_test.go` - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package hackforger_test - -import ( - "testing" - - "forgejo.org/models/unittest" - - _ "forgejo.org/modules/testimport" -) - -func TestMain(m *testing.M) { - unittest.MainTest(m) -} -``` - -- [ ] **Step 2: Write state machine tests** - -File: `services/hackforger/hackathon_test.go` - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package hackforger_test - -import ( - "testing" - - "forgejo.org/models/db" - hackforger_model "forgejo.org/models/hackforger" - "forgejo.org/models/unittest" - hackforger_service "forgejo.org/services/hackforger" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestPublishHackathon(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - ctx := db.DefaultContext - - h := &hackforger_model.Hackathon{OrgID: 1, OwnerID: 1, Name: "Pub Test", Slug: "pub-test"} - require.NoError(t, hackforger_model.CreateHackathon(ctx, h)) - - // Should fail: no tracks - err := hackforger_service.PublishHackathon(ctx, 1, h) - assert.Error(t, err) - - // Add a track - require.NoError(t, hackforger_model.CreateTrack(ctx, &hackforger_model.HackathonTrack{ - HackathonID: h.ID, Name: "Track 1", - })) - - // Now should succeed - require.NoError(t, hackforger_service.PublishHackathon(ctx, 1, h)) - - got, err := hackforger_model.GetHackathonByID(ctx, h.ID) - require.NoError(t, err) - assert.Equal(t, hackforger_model.HackathonStatusOpen, got.Status) -} - -func TestStartJudgingRequiresSubmission(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - ctx := db.DefaultContext - - h := &hackforger_model.Hackathon{OrgID: 1, OwnerID: 1, Name: "Judge Test", Slug: "judge-test"} - require.NoError(t, hackforger_model.CreateHackathon(ctx, h)) - require.NoError(t, hackforger_model.CreateTrack(ctx, &hackforger_model.HackathonTrack{HackathonID: h.ID, Name: "T1"})) - require.NoError(t, hackforger_service.PublishHackathon(ctx, 1, h)) - require.NoError(t, hackforger_service.StartHacking(ctx, 1, h)) - - // No submissions — should fail - h, _ = hackforger_model.GetHackathonByID(ctx, h.ID) - err := hackforger_service.StartJudging(ctx, 1, h) - assert.Error(t, err) -} - -func TestFullLifecycle(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - ctx := db.DefaultContext - - // Create - h := &hackforger_model.Hackathon{OrgID: 1, OwnerID: 1, Name: "Full LC", Slug: "full-lc"} - require.NoError(t, hackforger_service.CreateHackathon(ctx, 1, h)) - - // Add track - require.NoError(t, hackforger_model.CreateTrack(ctx, &hackforger_model.HackathonTrack{HackathonID: h.ID, Name: "T1"})) - - // Publish - require.NoError(t, hackforger_service.PublishHackathon(ctx, 1, h)) - - // Register + approve - reg := &hackforger_model.HackathonRegistration{HackathonID: h.ID, UserID: 2, TeamName: "Team A"} - require.NoError(t, hackforger_model.CreateRegistration(ctx, reg)) - require.NoError(t, hackforger_model.UpdateRegistrationStatus(ctx, reg.ID, hackforger_model.RegistrationStatusApproved)) - - // Start hacking - h, _ = hackforger_model.GetHackathonByID(ctx, h.ID) - require.NoError(t, hackforger_service.StartHacking(ctx, 1, h)) - - // Submit - sub := &hackforger_model.HackathonSubmission{ - HackathonID: h.ID, RegistrationID: reg.ID, UserID: 2, - Title: "Project X", DemoURL: "https://example.com", - } - require.NoError(t, hackforger_model.CreateSubmission(ctx, sub)) - - // Start judging - h, _ = hackforger_model.GetHackathonByID(ctx, h.ID) - require.NoError(t, hackforger_service.StartJudging(ctx, 1, h)) - - // Add judge + score - require.NoError(t, hackforger_model.AddJudge(ctx, h.ID, 10)) - require.NoError(t, hackforger_service.SubmitScore(ctx, 10, sub.ID, 8.5, "Nice")) - - // Finalize - h, _ = hackforger_model.GetHackathonByID(ctx, h.ID) - require.NoError(t, hackforger_service.FinalizeHackathon(ctx, 1, h)) - - h, _ = hackforger_model.GetHackathonByID(ctx, h.ID) - assert.Equal(t, hackforger_model.HackathonStatusFinished, h.Status) - - // Check rank was computed - sub, _ = hackforger_model.GetSubmissionByID(ctx, sub.ID) - assert.Equal(t, 1, sub.Rank) - assert.Equal(t, 8.5, sub.TotalScore) -} - -func TestCancelHackathon(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - ctx := db.DefaultContext - - h := &hackforger_model.Hackathon{OrgID: 1, OwnerID: 1, Name: "Cancel Test", Slug: "cancel-test"} - require.NoError(t, hackforger_model.CreateHackathon(ctx, h)) - - require.NoError(t, hackforger_service.CancelHackathon(ctx, 1, h)) - got, _ := hackforger_model.GetHackathonByID(ctx, h.ID) - assert.Equal(t, hackforger_model.HackathonStatusCancelled, got.Status) - - // Can't cancel Finished - h2 := &hackforger_model.Hackathon{OrgID: 1, OwnerID: 1, Name: "Fin", Slug: "fin", Status: hackforger_model.HackathonStatusFinished} - require.NoError(t, hackforger_model.CreateHackathon(ctx, h2)) - err := hackforger_service.CancelHackathon(ctx, 1, h2) - assert.Error(t, err) -} -``` - -- [ ] **Step 3: Run tests** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-hackathon && TAGS="bindata sqlite sqlite_unlock_notify" go test ./services/hackforger/... -v -count=1` -Expected: All tests PASS - -- [ ] **Step 4: Commit** - -```bash -git add services/hackforger/main_test.go services/hackforger/hackathon_test.go -git commit -m "test(hackforger): add Hackathon service tests (state machine + scoring)" -``` - ---- - -## Chunk 3: API Layer - -**API form binding pattern:** Forgejo does NOT use `ctx.BindJSON()`. Instead: -1. Define form structs (can be inline in the handler file or in `modules/structs/`) -2. Route registration uses `bind(FormStruct{})` middleware: `m.Post("/hackathons", reqToken(), bind(CreateHackathonForm{}), handler)` -3. Handler retrieves the form via: `form := web.GetForm(ctx).(*CreateHackathonForm)` -4. Import `"forgejo.org/services/web"` for `web.GetForm()` - -The code blocks below show the form structs inline. Route registration in Task 15 must include `bind()` calls for every POST/PUT route that takes a request body. - -**Path parameters:** Use `ctx.ParamsInt64(":id")` and `ctx.Params(":slug")` — note the colon prefix. Route registration uses `{id}`, `{slug}` syntax (chi router). - -### Task 12: Hackathon API — CRUD + State Transitions - -**Files:** -- Modify: `routers/api/v1/hackforger/hackathon.go` -- Modify: `routers/api/v1/api.go` - -- [ ] **Step 1: Rewrite hackathon.go API handlers** - -Replace the stub in `routers/api/v1/hackforger/hackathon.go` with full CRUD + state transition handlers. This is a large file (~400 lines). Key handlers: - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package hackforger - -import ( - "net/http" - "strconv" - - hackforger_model "forgejo.org/models/hackforger" - "forgejo.org/services/context" - hackforger_service "forgejo.org/services/hackforger" -) - -// ListHackathons returns a paginated list of hackathons. -func ListHackathons(ctx *context.APIContext) { - // swagger:operation GET /hackforger/hackathons hackforger hackforgerListHackathons - // --- - // summary: List hackathons - // produces: - // - application/json - // parameters: - // - name: status - // in: query - // description: Filter by status (0=draft,1=open,2=hacking,3=judging,4=finished,5=cancelled) - // type: integer - // - name: org_id - // in: query - // description: Filter by organization ID - // type: integer - // - name: q - // in: query - // description: Search keyword - // type: string - // - name: page - // in: query - // description: Page number - // type: integer - // - name: limit - // in: query - // description: Page size - // type: integer - // responses: - // "200": - // description: "HackathonList" - // schema: - // type: array - // items: - // "$ref": "#/definitions/Hackathon" - - opts := hackforger_model.ListHackathonsOptions{ - Keyword: ctx.FormString("q"), - } - opts.Page = ctx.FormInt("page") - opts.PageSize = ctx.FormInt("limit") - if opts.Page <= 0 { - opts.Page = 1 - } - if opts.PageSize <= 0 || opts.PageSize > 50 { - opts.PageSize = 20 - } - - if orgID := ctx.FormInt64("org_id"); orgID > 0 { - opts.OrgID = orgID - } - if statusStr := ctx.FormString("status"); statusStr != "" { - if s, err := strconv.Atoi(statusStr); err == nil { - status := hackforger_model.HackathonStatus(s) - opts.Status = &status - } - } - - hackathons, count, err := hackforger_model.ListHackathons(ctx, opts) - if err != nil { - ctx.InternalServerError(err) - return - } - - ctx.SetTotalCountHeader(count) - ctx.JSON(http.StatusOK, hackathons) -} - -// GetHackathon returns a single hackathon by ID. -func GetHackathon(ctx *context.APIContext) { - // swagger:operation GET /hackforger/hackathons/{id} hackforger hackforgerGetHackathon - // --- - // summary: Get a hackathon - // produces: - // - application/json - // parameters: - // - name: id - // in: path - // description: Hackathon ID - // type: integer - // required: true - // responses: - // "200": - // "$ref": "#/definitions/Hackathon" - // "404": - // "$ref": "#/responses/notFound" - - h, err := hackforger_model.GetHackathonByID(ctx, ctx.ParamsInt64(":id")) - if err != nil { - if hackforger_model.IsErrHackathonNotExist(err) { - ctx.NotFound() - return - } - ctx.InternalServerError(err) - return - } - ctx.JSON(http.StatusOK, h) -} - -// CreateHackathon creates a new hackathon. -func CreateHackathon(ctx *context.APIContext) { - // swagger:operation POST /hackforger/hackathons hackforger hackforgerCreateHackathon - // --- - // summary: Create a hackathon - // consumes: - // - application/json - // produces: - // - application/json - // responses: - // "201": - // "$ref": "#/definitions/Hackathon" - // "422": - // "$ref": "#/responses/validationError" - - type createForm struct { - Name string `json:"name" binding:"Required"` - Slug string `json:"slug" binding:"Required;AlphaDashDot;MaxSize(100)"` - Description string `json:"description"` - OrgID int64 `json:"org_id" binding:"Required"` - MaxTeamSize int `json:"max_team_size"` - } - - form := &createForm{} - // NOTE: Form binding is handled by bind() middleware in route registration. - // Handler receives the form via: form := web.GetForm(ctx).(*formType) - - h := &hackforger_model.Hackathon{ - OrgID: form.OrgID, - OwnerID: ctx.Doer.ID, - Name: form.Name, - Slug: form.Slug, - Description: form.Description, - MaxTeamSize: form.MaxTeamSize, - } - if h.MaxTeamSize <= 0 { - h.MaxTeamSize = 5 - } - - if err := hackforger_service.CreateHackathon(ctx, ctx.Doer.ID, h); err != nil { - if hackforger_model.IsErrHackathonSlugAlreadyExist(err) { - ctx.Error(http.StatusConflict, "SlugExists", err) - return - } - ctx.InternalServerError(err) - return - } - ctx.JSON(http.StatusCreated, h) -} - -// UpdateHackathon updates a hackathon. -func UpdateHackathon(ctx *context.APIContext) { - // swagger:operation PUT /hackforger/hackathons/{id} hackforger hackforgerUpdateHackathon - // --- - // summary: Update a hackathon - // parameters: - // - name: id - // in: path - // type: integer - // required: true - // responses: - // "200": - // "$ref": "#/definitions/Hackathon" - - h, err := hackforger_model.GetHackathonByID(ctx, ctx.ParamsInt64(":id")) - if err != nil { - if hackforger_model.IsErrHackathonNotExist(err) { - ctx.NotFound() - return - } - ctx.InternalServerError(err) - return - } - - type updateForm struct { - Name *string `json:"name"` - Description *string `json:"description"` - MaxTeamSize *int `json:"max_team_size"` - PrizeSummary *string `json:"prize_summary"` - } - - form := &updateForm{} - // NOTE: Form binding is handled by bind() middleware in route registration. - // Handler receives the form via: form := web.GetForm(ctx).(*formType) - - if form.Name != nil { - h.Name = *form.Name - } - if form.Description != nil { - h.Description = *form.Description - } - if form.MaxTeamSize != nil { - h.MaxTeamSize = *form.MaxTeamSize - } - if form.PrizeSummary != nil { - h.PrizeSummary = *form.PrizeSummary - } - - if err := hackforger_model.UpdateHackathon(ctx, h); err != nil { - ctx.InternalServerError(err) - return - } - ctx.JSON(http.StatusOK, h) -} - -// DeleteHackathon deletes a hackathon (Draft only). -func DeleteHackathon(ctx *context.APIContext) { - // swagger:operation DELETE /hackforger/hackathons/{id} hackforger hackforgerDeleteHackathon - // --- - // summary: Delete a hackathon (Draft only) - // parameters: - // - name: id - // in: path - // type: integer - // required: true - // responses: - // "204": - // "$ref": "#/responses/empty" - - if err := hackforger_model.DeleteHackathon(ctx, ctx.ParamsInt64(":id")); err != nil { - if hackforger_model.IsErrHackathonNotExist(err) { - ctx.NotFound() - return - } - ctx.Error(http.StatusForbidden, "DeleteHackathon", err) - return - } - ctx.Status(http.StatusNoContent) -} - -// helper to load hackathon from path param -func getHackathonFromPath(ctx *context.APIContext) *hackforger_model.Hackathon { - h, err := hackforger_model.GetHackathonByID(ctx, ctx.ParamsInt64(":id")) - if err != nil { - if hackforger_model.IsErrHackathonNotExist(err) { - ctx.NotFound() - } else { - ctx.InternalServerError(err) - } - return nil - } - return h -} - -// PublishHackathon transitions Draft → Open. -func PublishHackathon(ctx *context.APIContext) { - h := getHackathonFromPath(ctx) - if h == nil { - return - } - if err := hackforger_service.PublishHackathon(ctx, ctx.Doer.ID, h); err != nil { - ctx.Error(http.StatusBadRequest, "PublishHackathon", err) - return - } - ctx.JSON(http.StatusOK, map[string]string{"status": "open"}) -} - -// StartHackathon transitions Open → Hacking. -func StartHackathon(ctx *context.APIContext) { - h := getHackathonFromPath(ctx) - if h == nil { - return - } - if err := hackforger_service.StartHacking(ctx, ctx.Doer.ID, h); err != nil { - ctx.Error(http.StatusBadRequest, "StartHacking", err) - return - } - ctx.JSON(http.StatusOK, map[string]string{"status": "hacking"}) -} - -// StartJudgingHackathon transitions Hacking → Judging. -func StartJudgingHackathon(ctx *context.APIContext) { - h := getHackathonFromPath(ctx) - if h == nil { - return - } - if err := hackforger_service.StartJudging(ctx, ctx.Doer.ID, h); err != nil { - ctx.Error(http.StatusBadRequest, "StartJudging", err) - return - } - ctx.JSON(http.StatusOK, map[string]string{"status": "judging"}) -} - -// FinalizeHackathon transitions Judging → Finished. -func FinalizeHackathon(ctx *context.APIContext) { - h := getHackathonFromPath(ctx) - if h == nil { - return - } - if err := hackforger_service.FinalizeHackathon(ctx, ctx.Doer.ID, h); err != nil { - ctx.Error(http.StatusBadRequest, "FinalizeHackathon", err) - return - } - ctx.JSON(http.StatusOK, map[string]string{"status": "finished"}) -} - -// CancelHackathon transitions to Cancelled. -func CancelHackathon(ctx *context.APIContext) { - h := getHackathonFromPath(ctx) - if h == nil { - return - } - if err := hackforger_service.CancelHackathon(ctx, ctx.Doer.ID, h); err != nil { - ctx.Error(http.StatusBadRequest, "CancelHackathon", err) - return - } - ctx.JSON(http.StatusOK, map[string]string{"status": "cancelled"}) -} -``` - -- [ ] **Step 2: Register routes in api.go** - -In `routers/api/v1/api.go`, expand the hackforger group (around line 1740): - -Replace the existing hackforger group with: -```go -// HackForger API routes -m.Group("/hackforger", func() { - // Hackathon CRUD + state transitions - m.Get("/hackathons", hackforger_api.ListHackathons) - m.Post("/hackathons", reqToken(), hackforger_api.CreateHackathon) - m.Group("/hackathons/{id}", func() { - m.Get("", hackforger_api.GetHackathon) - m.Put("", reqToken(), hackforger_api.UpdateHackathon) - m.Delete("", reqToken(), hackforger_api.DeleteHackathon) - m.Post("/publish", reqToken(), hackforger_api.PublishHackathon) - m.Post("/start", reqToken(), hackforger_api.StartHackathon) - m.Post("/start-judging", reqToken(), hackforger_api.StartJudgingHackathon) - m.Post("/finalize", reqToken(), hackforger_api.FinalizeHackathon) - m.Post("/cancel", reqToken(), hackforger_api.CancelHackathon) - // Tracks - m.Get("/tracks", hackforger_api.ListTracks) - m.Post("/tracks", reqToken(), hackforger_api.CreateTrack) - m.Put("/tracks/{tid}", reqToken(), hackforger_api.UpdateTrack) - m.Delete("/tracks/{tid}", reqToken(), hackforger_api.DeleteTrack) - // Registrations - m.Post("/register", reqToken(), hackforger_api.Register) - m.Get("/registrations", hackforger_api.ListRegistrations) - m.Put("/registrations/{rid}", reqToken(), hackforger_api.UpdateRegistration) - // Submissions - m.Post("/submissions", reqToken(), hackforger_api.CreateSubmission) - m.Get("/submissions", hackforger_api.ListSubmissions) - m.Get("/submissions/{sid}", hackforger_api.GetSubmission) - m.Put("/submissions/{sid}", reqToken(), hackforger_api.UpdateSubmission) - // Judges - m.Get("/judges", hackforger_api.ListJudges) - m.Post("/judges", reqToken(), hackforger_api.AddJudge) - m.Delete("/judges/{uid}", reqToken(), hackforger_api.RemoveJudge) - // Scores - m.Post("/submissions/{sid}/score", reqToken(), hackforger_api.SubmitScore) - m.Get("/submissions/{sid}/scores", hackforger_api.ListScores) - // Leaderboard - m.Get("/leaderboard", hackforger_api.GetLeaderboard) - }) - - // Keep existing stubs for other modules - m.Get("/bounties", hackforger_api.ListBounties) - m.Get("/grants/rounds", hackforger_api.ListGrantRounds) - m.Get("/credits/balance", hackforger_api.GetBalance) - m.Get("/feed", hackforger_api.GetFeed) -}) -``` - -Note: `reqToken()` is already defined in api.go — it enforces API token authentication. - -- [ ] **Step 3: Verify compilation** - -Run: `go build ./routers/api/v1/...` -Expected: Will fail because Track/Registration/Submission/Judge/Score/Leaderboard handlers don't exist yet. That's expected — commit the CRUD + state transition handlers first. - -Actually, this will block compilation. We need to at minimum create stub functions for all referenced handlers. Add them as stubs in new files (Task 13-15). - -- [ ] **Step 4: Commit CRUD + state handlers (but don't update api.go yet — defer to after all handlers exist)** - -```bash -git add routers/api/v1/hackforger/hackathon.go -git commit -m "feat(hackforger): add Hackathon CRUD and state transition API handlers" -``` - ---- - -### Task 13: Track, Registration, Submission API Handlers - -**Files:** -- Create: `routers/api/v1/hackforger/hackathon_track.go` -- Create: `routers/api/v1/hackforger/hackathon_registration.go` -- Create: `routers/api/v1/hackforger/hackathon_submission.go` - -- [ ] **Step 1: Write track API handlers** - -File: `routers/api/v1/hackforger/hackathon_track.go` - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package hackforger - -import ( - "net/http" - - hackforger_model "forgejo.org/models/hackforger" - "forgejo.org/services/context" -) - -func ListTracks(ctx *context.APIContext) { - tracks, err := hackforger_model.ListTracksByHackathon(ctx, ctx.ParamsInt64(":id")) - if err != nil { - ctx.InternalServerError(err) - return - } - ctx.JSON(http.StatusOK, tracks) -} - -func CreateTrack(ctx *context.APIContext) { - type form struct { - Name string `json:"name" binding:"Required"` - Description string `json:"description"` - PrizeAmount float64 `json:"prize_amount"` - PrizeCurrency string `json:"prize_currency"` - PrizeCredits int64 `json:"prize_credits"` - } - f := &form{} - // NOTE: Form binding is handled by bind() middleware in route registration. - // Handler receives the form via: f := web.GetForm(ctx).(*formType) - - t := &hackforger_model.HackathonTrack{ - HackathonID: ctx.ParamsInt64(":id"), - Name: f.Name, - Description: f.Description, - PrizeAmount: f.PrizeAmount, - PrizeCurrency: f.PrizeCurrency, - PrizeCredits: f.PrizeCredits, - } - if err := hackforger_model.CreateTrack(ctx, t); err != nil { - ctx.InternalServerError(err) - return - } - ctx.JSON(http.StatusCreated, t) -} - -func UpdateTrack(ctx *context.APIContext) { - t, err := hackforger_model.GetTrackByID(ctx, ctx.ParamsInt64(":tid")) - if err != nil { - if hackforger_model.IsErrTrackNotExist(err) { - ctx.NotFound() - return - } - ctx.InternalServerError(err) - return - } - - type form struct { - Name *string `json:"name"` - Description *string `json:"description"` - PrizeAmount *float64 `json:"prize_amount"` - PrizeCurrency *string `json:"prize_currency"` - PrizeCredits *int64 `json:"prize_credits"` - } - f := &form{} - // NOTE: Form binding is handled by bind() middleware in route registration. - // Handler receives the form via: f := web.GetForm(ctx).(*formType) - if f.Name != nil { - t.Name = *f.Name - } - if f.Description != nil { - t.Description = *f.Description - } - if f.PrizeAmount != nil { - t.PrizeAmount = *f.PrizeAmount - } - if f.PrizeCurrency != nil { - t.PrizeCurrency = *f.PrizeCurrency - } - if f.PrizeCredits != nil { - t.PrizeCredits = *f.PrizeCredits - } - - if err := hackforger_model.UpdateTrack(ctx, t); err != nil { - ctx.InternalServerError(err) - return - } - ctx.JSON(http.StatusOK, t) -} - -func DeleteTrack(ctx *context.APIContext) { - if err := hackforger_model.DeleteTrack(ctx, ctx.ParamsInt64(":tid")); err != nil { - ctx.InternalServerError(err) - return - } - ctx.Status(http.StatusNoContent) -} -``` - -- [ ] **Step 2: Write registration API handlers** - -File: `routers/api/v1/hackforger/hackathon_registration.go` - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package hackforger - -import ( - "net/http" - - hackforger_model "forgejo.org/models/hackforger" - "forgejo.org/services/context" -) - -func Register(ctx *context.APIContext) { - type form struct { - TeamName string `json:"team_name" binding:"Required"` - TrackID int64 `json:"track_id"` - } - f := &form{} - // NOTE: Form binding is handled by bind() middleware in route registration. - // Handler receives the form via: f := web.GetForm(ctx).(*formType) - - // Verify hackathon is in Open status - h, err := hackforger_model.GetHackathonByID(ctx, ctx.ParamsInt64(":id")) - if err != nil { - if hackforger_model.IsErrHackathonNotExist(err) { - ctx.NotFound() - return - } - ctx.InternalServerError(err) - return - } - if h.Status != hackforger_model.HackathonStatusOpen { - ctx.Error(http.StatusBadRequest, "InvalidPhase", "hackathon is not accepting registrations") - return - } - - r := &hackforger_model.HackathonRegistration{ - HackathonID: h.ID, - UserID: ctx.Doer.ID, - TeamName: f.TeamName, - TrackID: f.TrackID, - } - if err := hackforger_model.CreateRegistration(ctx, r); err != nil { - if hackforger_model.IsErrDuplicateRegistration(err) { - ctx.Error(http.StatusConflict, "AlreadyRegistered", err) - return - } - ctx.InternalServerError(err) - return - } - ctx.JSON(http.StatusCreated, r) -} - -func ListRegistrations(ctx *context.APIContext) { - opts := hackforger_model.ListRegistrationsOptions{ - HackathonID: ctx.ParamsInt64(":id"), - } - opts.Page = ctx.FormInt("page") - opts.PageSize = ctx.FormInt("limit") - if opts.Page <= 0 { - opts.Page = 1 - } - if opts.PageSize <= 0 || opts.PageSize > 50 { - opts.PageSize = 20 - } - - regs, count, err := hackforger_model.ListRegistrations(ctx, opts) - if err != nil { - ctx.InternalServerError(err) - return - } - ctx.SetTotalCountHeader(count) - ctx.JSON(http.StatusOK, regs) -} - -func UpdateRegistration(ctx *context.APIContext) { - type form struct { - Status int `json:"status" binding:"Required"` - } - f := &form{} - // NOTE: Form binding is handled by bind() middleware in route registration. - // Handler receives the form via: f := web.GetForm(ctx).(*formType) - - rid := ctx.ParamsInt64(":rid") - status := hackforger_model.RegistrationStatus(f.Status) - - if err := hackforger_model.UpdateRegistrationStatus(ctx, rid, status); err != nil { - ctx.InternalServerError(err) - return - } - - // Publish feed event if approved - if status == hackforger_model.RegistrationStatusApproved { - r, err := hackforger_model.GetRegistrationByID(ctx, rid) - if err == nil { - h, err := hackforger_model.GetHackathonByID(ctx, r.HackathonID) - if err == nil { - _ = hackforger_service.PublishHackforgerAction(ctx, &hackforger_service.HackforgerActionOpts{ - ActUserID: r.UserID, - OpType: hackforger_model.ActionHackathonRegistered, - AudienceType: hackforger_service.AudienceFollowers, - Content: hackforger_model.HackforgerActionContent{ - EntityType: "hackathon", - EntityID: h.ID, - EntityName: h.Name, - EntitySlug: h.Slug, - }, - }) - } - } - } - - ctx.JSON(http.StatusOK, map[string]string{"status": "updated"}) -} -``` - -Note: The `UpdateRegistration` handler needs to import `hackforger_service`. Add this import: -```go -import ( - hackforger_service "forgejo.org/services/hackforger" -) -``` - -- [ ] **Step 3: Write submission API handlers** - -File: `routers/api/v1/hackforger/hackathon_submission.go` - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package hackforger - -import ( - "net/http" - - hackforger_model "forgejo.org/models/hackforger" - "forgejo.org/services/context" - hackforger_service "forgejo.org/services/hackforger" -) - -func CreateSubmission(ctx *context.APIContext) { - h, err := hackforger_model.GetHackathonByID(ctx, ctx.ParamsInt64(":id")) - if err != nil { - if hackforger_model.IsErrHackathonNotExist(err) { - ctx.NotFound() - return - } - ctx.InternalServerError(err) - return - } - if h.Status != hackforger_model.HackathonStatusHacking { - ctx.Error(http.StatusBadRequest, "InvalidPhase", "hackathon is not accepting submissions") - return - } - - // Verify user is registered - reg, err := hackforger_model.GetRegistration(ctx, h.ID, ctx.Doer.ID) - if err != nil { - ctx.Error(http.StatusForbidden, "NotRegistered", "user is not registered for this hackathon") - return - } - - type form struct { - Title string `json:"title" binding:"Required"` - Description string `json:"description"` - DemoURL string `json:"demo_url"` - TrackID int64 `json:"track_id"` - RepoID int64 `json:"repo_id"` - } - f := &form{} - // NOTE: Form binding is handled by bind() middleware in route registration. - // Handler receives the form via: f := web.GetForm(ctx).(*formType) - - s := &hackforger_model.HackathonSubmission{ - HackathonID: h.ID, - RegistrationID: reg.ID, - UserID: ctx.Doer.ID, - Title: f.Title, - Description: f.Description, - DemoURL: f.DemoURL, - TrackID: f.TrackID, - RepoID: f.RepoID, - Status: hackforger_model.SubmissionStatusSubmitted, - } - if err := hackforger_model.CreateSubmission(ctx, s); err != nil { - ctx.InternalServerError(err) - return - } - - // Feed event - _ = hackforger_service.PublishHackforgerAction(ctx, &hackforger_service.HackforgerActionOpts{ - ActUserID: ctx.Doer.ID, - OpType: hackforger_model.ActionHackathonSubmitted, - AudienceType: hackforger_service.AudienceFollowers, - Content: hackforger_model.HackforgerActionContent{ - EntityType: "hackathon", - EntityID: h.ID, - EntityName: h.Name, - EntitySlug: h.Slug, - Extra: map[string]any{"submission_id": s.ID, "title": s.Title}, - }, - }) - - ctx.JSON(http.StatusCreated, s) -} - -func ListSubmissions(ctx *context.APIContext) { - opts := hackforger_model.ListSubmissionsOptions{ - HackathonID: ctx.ParamsInt64(":id"), - } - opts.Page = ctx.FormInt("page") - opts.PageSize = ctx.FormInt("limit") - if opts.Page <= 0 { - opts.Page = 1 - } - if opts.PageSize <= 0 || opts.PageSize > 50 { - opts.PageSize = 20 - } - - subs, count, err := hackforger_model.ListSubmissions(ctx, opts) - if err != nil { - ctx.InternalServerError(err) - return - } - ctx.SetTotalCountHeader(count) - ctx.JSON(http.StatusOK, subs) -} - -func GetSubmission(ctx *context.APIContext) { - s, err := hackforger_model.GetSubmissionByID(ctx, ctx.ParamsInt64(":sid")) - if err != nil { - if hackforger_model.IsErrSubmissionNotExist(err) { - ctx.NotFound() - return - } - ctx.InternalServerError(err) - return - } - ctx.JSON(http.StatusOK, s) -} - -func UpdateSubmission(ctx *context.APIContext) { - s, err := hackforger_model.GetSubmissionByID(ctx, ctx.ParamsInt64(":sid")) - if err != nil { - if hackforger_model.IsErrSubmissionNotExist(err) { - ctx.NotFound() - return - } - ctx.InternalServerError(err) - return - } - - type form struct { - Title *string `json:"title"` - Description *string `json:"description"` - DemoURL *string `json:"demo_url"` - } - f := &form{} - // NOTE: Form binding is handled by bind() middleware in route registration. - // Handler receives the form via: f := web.GetForm(ctx).(*formType) - if f.Title != nil { - s.Title = *f.Title - } - if f.Description != nil { - s.Description = *f.Description - } - if f.DemoURL != nil { - s.DemoURL = *f.DemoURL - } - - if err := hackforger_model.UpdateSubmission(ctx, s); err != nil { - ctx.InternalServerError(err) - return - } - ctx.JSON(http.StatusOK, s) -} -``` - -- [ ] **Step 4: Verify compilation** - -Run: `go build ./routers/api/v1/hackforger/...` -Expected: No errors - -- [ ] **Step 5: Commit** - -```bash -git add routers/api/v1/hackforger/hackathon_track.go routers/api/v1/hackforger/hackathon_registration.go routers/api/v1/hackforger/hackathon_submission.go -git commit -m "feat(hackforger): add Track, Registration, Submission API handlers" -``` - ---- - -### Task 14: Judge, Score, Leaderboard API Handlers - -**Files:** -- Create: `routers/api/v1/hackforger/hackathon_judge.go` - -- [ ] **Step 1: Write judge + score + leaderboard handlers** - -File: `routers/api/v1/hackforger/hackathon_judge.go` - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package hackforger - -import ( - "net/http" - - hackforger_model "forgejo.org/models/hackforger" - "forgejo.org/services/context" - hackforger_service "forgejo.org/services/hackforger" -) - -func ListJudges(ctx *context.APIContext) { - judges, err := hackforger_model.ListJudges(ctx, ctx.ParamsInt64(":id")) - if err != nil { - ctx.InternalServerError(err) - return - } - ctx.JSON(http.StatusOK, judges) -} - -func AddJudge(ctx *context.APIContext) { - type form struct { - UserID int64 `json:"user_id" binding:"Required"` - } - f := &form{} - // NOTE: Form binding is handled by bind() middleware in route registration. - // Handler receives the form via: f := web.GetForm(ctx).(*formType) - - if err := hackforger_model.AddJudge(ctx, ctx.ParamsInt64(":id"), f.UserID); err != nil { - if hackforger_model.IsErrDuplicateJudge(err) { - ctx.Error(http.StatusConflict, "DuplicateJudge", err) - return - } - ctx.InternalServerError(err) - return - } - ctx.Status(http.StatusCreated) -} - -func RemoveJudge(ctx *context.APIContext) { - if err := hackforger_model.RemoveJudge(ctx, ctx.ParamsInt64(":id"), ctx.ParamsInt64(":uid")); err != nil { - ctx.InternalServerError(err) - return - } - ctx.Status(http.StatusNoContent) -} - -func SubmitScore(ctx *context.APIContext) { - type form struct { - Score float64 `json:"score"` - Comment string `json:"comment"` - } - f := &form{} - // NOTE: Form binding is handled by bind() middleware in route registration. - // Handler receives the form via: f := web.GetForm(ctx).(*formType) - - if err := hackforger_service.SubmitScore(ctx, ctx.Doer.ID, ctx.ParamsInt64(":sid"), f.Score, f.Comment); err != nil { - ctx.Error(http.StatusBadRequest, "SubmitScore", err) - return - } - ctx.Status(http.StatusCreated) -} - -func ListScores(ctx *context.APIContext) { - scores, err := hackforger_model.ListScoresBySubmission(ctx, ctx.ParamsInt64(":sid")) - if err != nil { - ctx.InternalServerError(err) - return - } - ctx.JSON(http.StatusOK, scores) -} - -func GetLeaderboard(ctx *context.APIContext) { - opts := hackforger_model.ListSubmissionsOptions{ - HackathonID: ctx.ParamsInt64(":id"), - } - // Get all submissions, no pagination for leaderboard - opts.Page = 1 - opts.PageSize = 100 - - subs, _, err := hackforger_model.ListSubmissions(ctx, opts) - if err != nil { - ctx.InternalServerError(err) - return - } - - // Sort by rank (already set during finalize) - // Return as-is — they should already be ranked - type entry struct { - Rank int `json:"rank"` - Title string `json:"title"` - UserID int64 `json:"user_id"` - TotalScore float64 `json:"total_score"` - DemoURL string `json:"demo_url"` - } - - result := make([]entry, 0, len(subs)) - for _, s := range subs { - result = append(result, entry{ - Rank: s.Rank, - Title: s.Title, - UserID: s.UserID, - TotalScore: s.TotalScore, - DemoURL: s.DemoURL, - }) - } - ctx.JSON(http.StatusOK, result) -} -``` - -- [ ] **Step 2: Verify compilation** - -Run: `go build ./routers/api/v1/hackforger/...` -Expected: No errors - -- [ ] **Step 3: Commit** - -```bash -git add routers/api/v1/hackforger/hackathon_judge.go -git commit -m "feat(hackforger): add Judge, Score, Leaderboard API handlers" -``` - ---- - -### Task 15: Register All API Routes in api.go - -**Files:** -- Modify: `routers/api/v1/api.go` - -- [ ] **Step 1: Update the hackforger route group** - -Find the existing hackforger group (around line 1739-1746) and replace with the full route tree from Task 12 Step 2. - -- [ ] **Step 2: Verify compilation** - -Run: `go build ./routers/api/v1/...` -Expected: No errors - -- [ ] **Step 3: Commit** - -```bash -git add routers/api/v1/api.go -git commit -m "feat(hackforger): register all Hackathon API routes" -``` - ---- - -## Chunk 4: Web Layer (Templates + Routes) - -### Task 16: i18n Keys - -**Files:** -- Modify: `options/locale/locale_en-US.ini` - -- [ ] **Step 1: Add hackathon-specific i18n keys** - -Append to the `[hackforger]` section: - -```ini -hackathon.create = Create Hackathon -hackathon.edit = Edit Hackathon -hackathon.delete_confirm = Are you sure you want to delete this hackathon? -hackathon.field.name = Name -hackathon.field.slug = Slug -hackathon.field.description = Description -hackathon.field.org = Organization -hackathon.field.max_team_size = Max Team Size -hackathon.field.prize_summary = Prize Summary -hackathon.field.registration_start = Registration Start -hackathon.field.registration_end = Registration End -hackathon.field.hacking_start = Hacking Start -hackathon.field.hacking_end = Hacking End -hackathon.field.judging_end = Judging End -hackathon.register = Register -hackathon.register.success = Successfully registered! -hackathon.register.already_registered = You are already registered for this hackathon. -hackathon.submit = Submit Project -hackathon.submit.success = Project submitted successfully! -hackathon.submit.field.title = Project Title -hackathon.submit.field.demo_url = Demo URL -hackathon.submit.field.description = Description -hackathon.submit.field.track = Track -hackathon.manage = Manage Hackathon -hackathon.manage.tracks = Tracks -hackathon.manage.tracks.add = Add Track -hackathon.manage.registrations = Registrations -hackathon.manage.judges = Judges -hackathon.manage.judges.add = Add Judge -hackathon.manage.phase.publish = Publish -hackathon.manage.phase.start = Start Hacking -hackathon.manage.phase.start_judging = Start Judging -hackathon.manage.phase.finalize = Finalize -hackathon.manage.phase.cancel = Cancel -hackathon.judge = Judge -hackathon.judge.score = Score (0-10) -hackathon.judge.comment = Comment -hackathon.judge.submit_score = Submit Score -hackathon.leaderboard = Leaderboard -hackathon.leaderboard.rank = Rank -hackathon.leaderboard.team = Team / Project -hackathon.leaderboard.score = Score -hackathon.leaderboard.track = Track -hackathon.error.not_found = Hackathon not found. -hackathon.error.invalid_phase = This action is not available in the current phase. -hackathon.error.no_permission = You do not have permission to perform this action. -hackathon.no_hackathons = No hackathons found. -hackathon.status.cancelled = Cancelled -``` - -- [ ] **Step 2: Commit** - -```bash -git add options/locale/locale_en-US.ini -git commit -m "i18n(hackforger): add hackathon web page i18n keys" -``` - ---- - -### Task 17: Web Templates - -**Files:** -- Create: `templates/hackforger/hackathon/new.tmpl` -- Create: `templates/hackforger/hackathon/view.tmpl` -- Create: `templates/hackforger/hackathon/submit.tmpl` -- Create: `templates/hackforger/hackathon/manage.tmpl` -- Create: `templates/hackforger/hackathon/judge.tmpl` -- Create: `templates/hackforger/hackathon/leaderboard.tmpl` -- Modify: `templates/hackforger/explore.tmpl` - -- [ ] **Step 1: Create hackathon/new.tmpl** - -```html -{{template "base/head" .}} -
-
-

{{ctx.Locale.Tr "hackforger.hackathon.create"}}

-
- {{.CsrfTokenHtml}} -
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- -
-
-
-{{template "base/footer" .}} -``` - -- [ ] **Step 2: Create hackathon/view.tmpl** - -```html -{{template "base/head" .}} -
-
-

{{.Hackathon.Name}}

-
- {{if eq .Hackathon.Status 0}}{{ctx.Locale.Tr "hackforger.hackathon.status.draft"}} - {{else if eq .Hackathon.Status 1}}{{ctx.Locale.Tr "hackforger.hackathon.status.registration"}} - {{else if eq .Hackathon.Status 2}}{{ctx.Locale.Tr "hackforger.hackathon.status.hacking"}} - {{else if eq .Hackathon.Status 3}}{{ctx.Locale.Tr "hackforger.hackathon.status.judging"}} - {{else if eq .Hackathon.Status 4}}{{ctx.Locale.Tr "hackforger.hackathon.status.finished"}} - {{else}}{{ctx.Locale.Tr "hackforger.hackathon.status.cancelled"}}{{end}} -
- - {{if .Hackathon.Description}} -
-

{{.Hackathon.Description}}

-
- {{end}} - - {{if .Hackathon.PrizeSummary}} -
-

Prizes

-

{{.Hackathon.PrizeSummary}}

-
- {{end}} - - {{/* Tracks */}} - {{if .Tracks}} -

{{ctx.Locale.Tr "hackforger.hackathon.manage.tracks"}}

-
- {{range .Tracks}} -
-
-
{{.Name}}
- {{if .Description}}
{{.Description}}
{{end}} -
-
- {{end}} -
- {{end}} - - {{/* Registration button (Open phase only) */}} - {{if and (eq .Hackathon.Status 1) .SignedUserID (not .IsRegistered)}} -
- {{.CsrfTokenHtml}} -
-
- - -
- -
-
- {{end}} - - {{/* Submissions list */}} - {{if .Submissions}} -

Submissions ({{len .Submissions}})

- - - - {{range .Submissions}} - - - - - - - {{end}} - -
ProjectDemoScoreRank
{{.Title}}{{if .DemoURL}}Demo{{end}}{{if gt .TotalScore 0.0}}{{printf "%.1f" .TotalScore}}{{else}}-{{end}}{{if gt .Rank 0}}#{{.Rank}}{{else}}-{{end}}
- {{end}} - - {{/* Submit link (Hacking phase, registered users) */}} - {{if and (eq .Hackathon.Status 2) .IsRegistered}} - - {{ctx.Locale.Tr "hackforger.hackathon.submit"}} - - {{end}} - - {{/* Leaderboard link */}} - {{if eq .Hackathon.Status 4}} - - {{ctx.Locale.Tr "hackforger.hackathon.leaderboard"}} - - {{end}} -
-
-{{template "base/footer" .}} -``` - -- [ ] **Step 3: Create hackathon/submit.tmpl** - -```html -{{template "base/head" .}} -
-
-

{{ctx.Locale.Tr "hackforger.hackathon.submit"}} — {{.Hackathon.Name}}

-
- {{.CsrfTokenHtml}} -
- - -
-
- - -
-
- - -
- {{if .Tracks}} -
- - -
- {{end}} - -
-
-
-{{template "base/footer" .}} -``` - -- [ ] **Step 4: Create hackathon/manage.tmpl** - -```html -{{template "base/head" .}} -
-
-

{{ctx.Locale.Tr "hackforger.hackathon.manage"}} — {{.Hackathon.Name}}

- - {{/* Phase control buttons */}} -
-

Phase Control

- Current: {{.StatusLabel}} -
- {{if eq .Hackathon.Status 0}} -
- {{.CsrfTokenHtml}} - -
- {{else if eq .Hackathon.Status 1}} -
- {{.CsrfTokenHtml}} - -
- {{else if eq .Hackathon.Status 2}} -
- {{.CsrfTokenHtml}} - -
- {{else if eq .Hackathon.Status 3}} -
- {{.CsrfTokenHtml}} - -
- {{end}} - {{if and (ne .Hackathon.Status 4) (ne .Hackathon.Status 5)}} -
- {{.CsrfTokenHtml}} - -
- {{end}} -
-
- - {{/* Tracks */}} -
-

{{ctx.Locale.Tr "hackforger.hackathon.manage.tracks"}}

- {{range .Tracks}} -
{{.Name}}
- {{end}} - {{if eq .Hackathon.Status 0}} -
- {{.CsrfTokenHtml}} -
-
- -
-
- {{end}} -
- - {{/* Registrations */}} -
-

{{ctx.Locale.Tr "hackforger.hackathon.manage.registrations"}} ({{len .Registrations}})

- - - - {{range .Registrations}} - - - - - - {{end}} - -
TeamStatusAction
{{.TeamName}}{{if eq .Status 0}}Pending{{else if eq .Status 1}}Approved{{else}}Rejected{{end}} - {{if eq .Status 0}} -
- {{$.CsrfTokenHtml}} - - -
-
- {{$.CsrfTokenHtml}} - - -
- {{end}} -
-
- - {{/* Judges */}} -
-

{{ctx.Locale.Tr "hackforger.hackathon.manage.judges"}} ({{len .Judges}})

- {{range .Judges}} -
User #{{.UserID}} -
- {{$.CsrfTokenHtml}} - -
-
- {{end}} -
- {{.CsrfTokenHtml}} -
-
- -
-
-
-
-
-{{template "base/footer" .}} -``` - -- [ ] **Step 5: Create hackathon/judge.tmpl** - -```html -{{template "base/head" .}} -
-
-

{{ctx.Locale.Tr "hackforger.hackathon.judge"}} — {{.Hackathon.Name}}

- {{range .Submissions}} -
-

{{.Title}}

- {{if .DemoURL}}

Demo

{{end}} - {{if .Description}}

{{.Description}}

{{end}} -
- {{$.CsrfTokenHtml}} -
-
- - -
-
- - -
- -
-
-
- {{end}} -
-
-{{template "base/footer" .}} -``` - -- [ ] **Step 6: Create hackathon/leaderboard.tmpl** - -```html -{{template "base/head" .}} -
-
-

{{ctx.Locale.Tr "hackforger.hackathon.leaderboard"}} — {{.Hackathon.Name}}

- - - - - - - - - - {{range .Submissions}} - - - - - - {{end}} - -
{{ctx.Locale.Tr "hackforger.hackathon.leaderboard.rank"}}{{ctx.Locale.Tr "hackforger.hackathon.leaderboard.team"}}{{ctx.Locale.Tr "hackforger.hackathon.leaderboard.score"}}
#{{.Rank}}{{.Title}}{{if .DemoURL}} — Demo{{end}}{{printf "%.1f" .TotalScore}}
-
-
-{{template "base/footer" .}} -``` - -- [ ] **Step 7: Update explore.tmpl to show hackathon listing** - -Replace `templates/hackforger/explore.tmpl` content: - -```html -{{template "base/head" .}} -
- {{template "explore/navbar" .}} -
- {{if .PageIsExploreHackathons}} -

{{ctx.Locale.Tr "hackforger.explore.hackathons"}}

- {{if .Hackathons}} -
- {{range .Hackathons}} -
-
- {{.Name}} -
- {{if eq .Status 0}}Draft{{else if eq .Status 1}}Open{{else if eq .Status 2}}Hacking{{else if eq .Status 3}}Judging{{else if eq .Status 4}}Finished{{else}}Cancelled{{end}} -
- {{if .Description}}
{{.Description}}
{{end}} -
-
- {{end}} -
- {{else}} -

{{ctx.Locale.Tr "hackforger.hackathon.no_hackathons"}}

- {{end}} - {{else if .PageIsExploreBounties}} -

{{ctx.Locale.Tr "hackforger.explore.bounties"}}

-

{{ctx.Locale.Tr "hackforger.explore.coming_soon"}}

- {{else if .PageIsExploreGrants}} -

{{ctx.Locale.Tr "hackforger.explore.grants"}}

-

{{ctx.Locale.Tr "hackforger.explore.coming_soon"}}

- {{end}} -
-
-{{template "base/footer" .}} -``` - -- [ ] **Step 8: Commit templates** - -```bash -git add templates/hackforger/ -git commit -m "feat(hackforger): add Hackathon web templates" -``` - ---- - -### Task 18: Web Route Handlers - -**Files:** -- Modify: `routers/web/hackforger/hackathon.go` -- Modify: `routers/web/web.go` - -- [ ] **Step 1: Rewrite web hackathon handlers** - -Replace `routers/web/hackforger/hackathon.go` with complete handlers for all web routes: - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package hackforger - -import ( - "net/http" - "strconv" - "strings" - - hackforger_model "forgejo.org/models/hackforger" - "forgejo.org/services/context" - hackforger_service "forgejo.org/services/hackforger" -) - -const ( - tplExplore = "hackforger/explore" - tplNew = "hackforger/hackathon/new" - tplView = "hackforger/hackathon/view" - tplSubmit = "hackforger/hackathon/submit" - tplManage = "hackforger/hackathon/manage" - tplJudge = "hackforger/hackathon/judge" - tplLeaderboard = "hackforger/hackathon/leaderboard" -) - -// ExploreHackathons renders the hackathon explore page. -func ExploreHackathons(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("hackforger.explore.hackathons") - ctx.Data["PageIsExploreHackathons"] = true - - hackathons, _, err := hackforger_model.ListHackathons(ctx, hackforger_model.ListHackathonsOptions{ - Keyword: ctx.FormString("q"), - }) - if err != nil { - ctx.ServerError("ListHackathons", err) - return - } - ctx.Data["Hackathons"] = hackathons - ctx.HTML(http.StatusOK, tplExplore) -} - -// ExploreBounties renders the bounty explore page. -func ExploreBounties(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("hackforger.explore.bounties") - ctx.Data["PageIsExploreBounties"] = true - ctx.HTML(http.StatusOK, tplExplore) -} - -// ExploreGrants renders the grants explore page. -func ExploreGrants(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("hackforger.explore.grants") - ctx.Data["PageIsExploreGrants"] = true - ctx.HTML(http.StatusOK, tplExplore) -} - -// NewHackathon renders the create hackathon form. -func NewHackathon(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("hackforger.hackathon.create") - ctx.HTML(http.StatusOK, tplNew) -} - -// NewHackathonPost handles hackathon creation form submission. -func NewHackathonPost(ctx *context.Context) { - maxTeamSize, _ := strconv.Atoi(ctx.FormString("max_team_size")) - if maxTeamSize <= 0 { - maxTeamSize = 5 - } - orgID, _ := strconv.ParseInt(ctx.FormString("org_id"), 10, 64) - - h := &hackforger_model.Hackathon{ - OrgID: orgID, - OwnerID: ctx.Doer.ID, - Name: ctx.FormString("name"), - Slug: ctx.FormString("slug"), - Description: ctx.FormString("description"), - PrizeSummary: ctx.FormString("prize_summary"), - MaxTeamSize: maxTeamSize, - } - if err := hackforger_service.CreateHackathon(ctx, ctx.Doer.ID, h); err != nil { - ctx.Data["Title"] = ctx.Tr("hackforger.hackathon.create") - ctx.RenderWithErr(err.Error(), tplNew, nil) - return - } - ctx.Redirect("/hackathon/" + h.Slug) -} - -// loadHackathon is a helper to load a hackathon by slug from the URL. -func loadHackathon(ctx *context.Context) *hackforger_model.Hackathon { - h, err := hackforger_model.GetHackathonBySlug(ctx, ctx.Params(":slug")) - if err != nil { - if hackforger_model.IsErrHackathonNotExist(err) { - ctx.NotFound(ctx.Tr("hackforger.hackathon.error.not_found"), nil) - } else { - ctx.ServerError("GetHackathonBySlug", err) - } - return nil - } - return h -} - -// ViewHackathon renders the hackathon detail page. -func ViewHackathon(ctx *context.Context) { - h := loadHackathon(ctx) - if h == nil { - return - } - ctx.Data["Title"] = h.Name - ctx.Data["Hackathon"] = h - - tracks, _ := hackforger_model.ListTracksByHackathon(ctx, h.ID) - ctx.Data["Tracks"] = tracks - - subs, _, _ := hackforger_model.ListSubmissions(ctx, hackforger_model.ListSubmissionsOptions{HackathonID: h.ID}) - ctx.Data["Submissions"] = subs - - if ctx.Doer != nil { - _, err := hackforger_model.GetRegistration(ctx, h.ID, ctx.Doer.ID) - ctx.Data["IsRegistered"] = err == nil - ctx.Data["SignedUserID"] = ctx.Doer.ID - } - - ctx.HTML(http.StatusOK, tplView) -} - -// RegisterPost handles hackathon registration. -func RegisterPost(ctx *context.Context) { - h := loadHackathon(ctx) - if h == nil { - return - } - if h.Status != hackforger_model.HackathonStatusOpen { - ctx.Flash.Error(ctx.Tr("hackforger.hackathon.error.invalid_phase")) - ctx.Redirect("/hackathon/" + h.Slug) - return - } - - r := &hackforger_model.HackathonRegistration{ - HackathonID: h.ID, - UserID: ctx.Doer.ID, - TeamName: ctx.FormString("team_name"), - } - if err := hackforger_model.CreateRegistration(ctx, r); err != nil { - ctx.Flash.Error(err.Error()) - ctx.Redirect("/hackathon/" + h.Slug) - return - } - ctx.Flash.Success(ctx.Tr("hackforger.hackathon.register.success")) - ctx.Redirect("/hackathon/" + h.Slug) -} - -// SubmitForm renders the submission form. -func SubmitForm(ctx *context.Context) { - h := loadHackathon(ctx) - if h == nil { - return - } - ctx.Data["Title"] = ctx.Tr("hackforger.hackathon.submit") - ctx.Data["Hackathon"] = h - - tracks, _ := hackforger_model.ListTracksByHackathon(ctx, h.ID) - ctx.Data["Tracks"] = tracks - - ctx.HTML(http.StatusOK, tplSubmit) -} - -// SubmitPost handles project submission. -func SubmitPost(ctx *context.Context) { - h := loadHackathon(ctx) - if h == nil { - return - } - if h.Status != hackforger_model.HackathonStatusHacking { - ctx.Flash.Error(ctx.Tr("hackforger.hackathon.error.invalid_phase")) - ctx.Redirect("/hackathon/" + h.Slug) - return - } - - reg, err := hackforger_model.GetRegistration(ctx, h.ID, ctx.Doer.ID) - if err != nil { - ctx.Flash.Error(ctx.Tr("hackforger.hackathon.error.no_permission")) - ctx.Redirect("/hackathon/" + h.Slug) - return - } - - trackID, _ := strconv.ParseInt(ctx.FormString("track_id"), 10, 64) - s := &hackforger_model.HackathonSubmission{ - HackathonID: h.ID, - RegistrationID: reg.ID, - UserID: ctx.Doer.ID, - Title: ctx.FormString("title"), - Description: ctx.FormString("description"), - DemoURL: ctx.FormString("demo_url"), - TrackID: trackID, - Status: hackforger_model.SubmissionStatusSubmitted, - } - if err := hackforger_model.CreateSubmission(ctx, s); err != nil { - ctx.Flash.Error(err.Error()) - ctx.Redirect("/hackathon/" + h.Slug + "/submit") - return - } - ctx.Flash.Success(ctx.Tr("hackforger.hackathon.submit.success")) - ctx.Redirect("/hackathon/" + h.Slug) -} - -// ManageHackathon renders the organizer management page. -func ManageHackathon(ctx *context.Context) { - h := loadHackathon(ctx) - if h == nil { - return - } - ctx.Data["Title"] = ctx.Tr("hackforger.hackathon.manage") - ctx.Data["Hackathon"] = h - ctx.Data["StatusLabel"] = hackforger_service.HackathonStatusLabel(h.Status) - - tracks, _ := hackforger_model.ListTracksByHackathon(ctx, h.ID) - ctx.Data["Tracks"] = tracks - - regs, _, _ := hackforger_model.ListRegistrations(ctx, hackforger_model.ListRegistrationsOptions{HackathonID: h.ID}) - ctx.Data["Registrations"] = regs - - judges, _ := hackforger_model.ListJudges(ctx, h.ID) - ctx.Data["Judges"] = judges - - ctx.HTML(http.StatusOK, tplManage) -} - -// ManagePhasePost handles phase transition POST requests from the manage page. -func ManagePhasePost(ctx *context.Context) { - h := loadHackathon(ctx) - if h == nil { - return - } - - // Extract the last path segment as the action (e.g., /manage/publish → "publish") - path := ctx.Req.URL.Path - segments := strings.Split(strings.TrimRight(path, "/"), "/") - action := segments[len(segments)-1] - var err error - switch action { - case "publish": - err = hackforger_service.PublishHackathon(ctx, ctx.Doer.ID, h) - case "start": - err = hackforger_service.StartHacking(ctx, ctx.Doer.ID, h) - case "start-judging": - err = hackforger_service.StartJudging(ctx, ctx.Doer.ID, h) - case "finalize": - err = hackforger_service.FinalizeHackathon(ctx, ctx.Doer.ID, h) - case "cancel": - err = hackforger_service.CancelHackathon(ctx, ctx.Doer.ID, h) - default: - ctx.NotFound("Unknown action", nil) - return - } - if err != nil { - ctx.Flash.Error(err.Error()) - } - ctx.Redirect("/hackathon/" + h.Slug + "/manage") -} - -// ManageTrackPost handles track creation from the manage page. -func ManageTrackPost(ctx *context.Context) { - h := loadHackathon(ctx) - if h == nil { - return - } - t := &hackforger_model.HackathonTrack{ - HackathonID: h.ID, - Name: ctx.FormString("name"), - } - if err := hackforger_model.CreateTrack(ctx, t); err != nil { - ctx.Flash.Error(err.Error()) - } - ctx.Redirect("/hackathon/" + h.Slug + "/manage") -} - -// ManageRegistrationPost handles registration approval/rejection. -func ManageRegistrationPost(ctx *context.Context) { - h := loadHackathon(ctx) - if h == nil { - return - } - rid := ctx.ParamsInt64(":rid") - status, _ := strconv.Atoi(ctx.FormString("status")) - if err := hackforger_model.UpdateRegistrationStatus(ctx, rid, hackforger_model.RegistrationStatus(status)); err != nil { - ctx.Flash.Error(err.Error()) - } - ctx.Redirect("/hackathon/" + h.Slug + "/manage") -} - -// ManageJudgePost handles adding a judge. -func ManageJudgePost(ctx *context.Context) { - h := loadHackathon(ctx) - if h == nil { - return - } - userID, _ := strconv.ParseInt(ctx.FormString("user_id"), 10, 64) - if err := hackforger_model.AddJudge(ctx, h.ID, userID); err != nil { - ctx.Flash.Error(err.Error()) - } - ctx.Redirect("/hackathon/" + h.Slug + "/manage") -} - -// ManageJudgeRemovePost handles removing a judge. -func ManageJudgeRemovePost(ctx *context.Context) { - h := loadHackathon(ctx) - if h == nil { - return - } - userID := ctx.ParamsInt64(":uid") - if err := hackforger_model.RemoveJudge(ctx, h.ID, userID); err != nil { - ctx.Flash.Error(err.Error()) - } - ctx.Redirect("/hackathon/" + h.Slug + "/manage") -} - -// JudgePage renders the judge scoring page. -func JudgePage(ctx *context.Context) { - h := loadHackathon(ctx) - if h == nil { - return - } - ctx.Data["Title"] = ctx.Tr("hackforger.hackathon.judge") - ctx.Data["Hackathon"] = h - - subs, _, _ := hackforger_model.ListSubmissions(ctx, hackforger_model.ListSubmissionsOptions{HackathonID: h.ID}) - ctx.Data["Submissions"] = subs - - ctx.HTML(http.StatusOK, tplJudge) -} - -// JudgeScorePost handles score submission from the judge page. -func JudgeScorePost(ctx *context.Context) { - h := loadHackathon(ctx) - if h == nil { - return - } - sid := ctx.ParamsInt64(":sid") - score, _ := strconv.ParseFloat(ctx.FormString("score"), 64) - comment := ctx.FormString("comment") - - if err := hackforger_service.SubmitScore(ctx, ctx.Doer.ID, sid, score, comment); err != nil { - ctx.Flash.Error(err.Error()) - } else { - ctx.Flash.Success("Score submitted") - } - ctx.Redirect("/hackathon/" + h.Slug + "/judge") -} - -// Leaderboard renders the public leaderboard. -func Leaderboard(ctx *context.Context) { - h := loadHackathon(ctx) - if h == nil { - return - } - ctx.Data["Title"] = ctx.Tr("hackforger.hackathon.leaderboard") - ctx.Data["Hackathon"] = h - - subs, _, _ := hackforger_model.ListSubmissions(ctx, hackforger_model.ListSubmissionsOptions{HackathonID: h.ID}) - ctx.Data["Submissions"] = subs - - ctx.HTML(http.StatusOK, tplLeaderboard) -} -``` - -- [ ] **Step 2: Add HackathonStatusLabel helper to services/hackforger/hackathon.go** - -Append to `services/hackforger/hackathon.go`: - -```go -// HackathonStatusLabel returns a human-readable status label. -func HackathonStatusLabel(status hackforger_model.HackathonStatus) string { - labels := map[hackforger_model.HackathonStatus]string{ - hackforger_model.HackathonStatusDraft: "Draft", - hackforger_model.HackathonStatusOpen: "Open (Registration)", - hackforger_model.HackathonStatusHacking: "Hacking", - hackforger_model.HackathonStatusJudging: "Judging", - hackforger_model.HackathonStatusFinished: "Finished", - hackforger_model.HackathonStatusCancelled: "Cancelled", - } - if l, ok := labels[status]; ok { - return l - } - return "Unknown" -} -``` - -- [ ] **Step 3: Register web routes in web.go** - -In `routers/web/web.go`, after the existing explore group (line ~507), add the hackathon route group: - -```go -// HackForger: Hackathon web routes -m.Group("", func() { - m.Get("/hackathons/new", hackforger_web.NewHackathon) - m.Post("/hackathons/new", web.Bind(forms.EmptyForm{}), hackforger_web.NewHackathonPost) - m.Get("/hackathon/{slug}", hackforger_web.ViewHackathon) - m.Post("/hackathon/{slug}/register", hackforger_web.RegisterPost) - m.Get("/hackathon/{slug}/submit", hackforger_web.SubmitForm) - m.Post("/hackathon/{slug}/submit", hackforger_web.SubmitPost) - m.Get("/hackathon/{slug}/leaderboard", hackforger_web.Leaderboard) - m.Group("/hackathon/{slug}/manage", func() { - m.Get("", hackforger_web.ManageHackathon) - m.Post("/publish", hackforger_web.ManagePhasePost) - m.Post("/start", hackforger_web.ManagePhasePost) - m.Post("/start-judging", hackforger_web.ManagePhasePost) - m.Post("/finalize", hackforger_web.ManagePhasePost) - m.Post("/cancel", hackforger_web.ManagePhasePost) - m.Post("/tracks", hackforger_web.ManageTrackPost) - m.Post("/registrations/{rid}", hackforger_web.ManageRegistrationPost) - m.Post("/judges", hackforger_web.ManageJudgePost) - m.Post("/judges/{uid}/remove", hackforger_web.ManageJudgeRemovePost) - }) - m.Group("/hackathon/{slug}/judge", func() { - m.Get("", hackforger_web.JudgePage) - m.Post("/{sid}/score", hackforger_web.JudgeScorePost) - }) -}, reqSignIn) -``` - -Note: `reqSignIn` applies to the entire group. The public routes (`/hackathon/{slug}` view and `/hackathon/{slug}/leaderboard`) should NOT require sign-in. Move them outside: - -```go -// HackForger: public hackathon routes -m.Get("/hackathon/{slug}", hackforger_web.ViewHackathon) -m.Get("/hackathon/{slug}/leaderboard", hackforger_web.Leaderboard) - -// HackForger: authenticated hackathon routes -m.Group("", func() { - m.Get("/hackathons/new", hackforger_web.NewHackathon) - m.Post("/hackathons/new", hackforger_web.NewHackathonPost) - m.Post("/hackathon/{slug}/register", hackforger_web.RegisterPost) - m.Get("/hackathon/{slug}/submit", hackforger_web.SubmitForm) - m.Post("/hackathon/{slug}/submit", hackforger_web.SubmitPost) - m.Group("/hackathon/{slug}/manage", func() { - m.Get("", hackforger_web.ManageHackathon) - m.Post("/publish", hackforger_web.ManagePhasePost) - m.Post("/start", hackforger_web.ManagePhasePost) - m.Post("/start-judging", hackforger_web.ManagePhasePost) - m.Post("/finalize", hackforger_web.ManagePhasePost) - m.Post("/cancel", hackforger_web.ManagePhasePost) - m.Post("/tracks", hackforger_web.ManageTrackPost) - m.Post("/registrations/{rid}", hackforger_web.ManageRegistrationPost) - m.Post("/judges", hackforger_web.ManageJudgePost) - m.Post("/judges/{uid}/remove", hackforger_web.ManageJudgeRemovePost) - }) - m.Group("/hackathon/{slug}/judge", func() { - m.Get("", hackforger_web.JudgePage) - m.Post("/{sid}/score", hackforger_web.JudgeScorePost) - }) -}, reqSignIn) -``` - -- [ ] **Step 4: Verify compilation** - -Run: `TAGS="bindata sqlite sqlite_unlock_notify" go build ./...` -Expected: No errors (full project build) - -- [ ] **Step 5: Commit** - -```bash -git add routers/web/hackforger/hackathon.go routers/web/web.go services/hackforger/hackathon.go -git commit -m "feat(hackforger): add Hackathon web routes, handlers, and templates" -``` - ---- - -## Chunk 5: Integration + Verification - -### Task 19: Full Build + Test Verification - -- [ ] **Step 1: Run full backend build** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/feat+phase1-hackathon && TAGS="bindata sqlite sqlite_unlock_notify" make backend` -Expected: Build succeeds - -- [ ] **Step 2: Run all hackforger tests** - -Run: `TAGS="bindata sqlite sqlite_unlock_notify" go test ./models/hackforger/... ./services/hackforger/... -v -count=1` -Expected: All tests PASS - -- [ ] **Step 3: Run linting** - -Run: `make lint-go-vet 2>&1 | head -50` -Expected: No errors in hackforger files - -- [ ] **Step 4: Verify templates compile (start server briefly)** - -Run: `./gitea web --port 3333 &` then `sleep 3 && curl -s http://localhost:3333/explore/hackathons | head -5` then kill the process. -Expected: HTML response with hackathon explore page - ---- - -### Task 20: Manual E2E Test Prompt - -After all implementation is complete, execute this manual E2E test on the internal instance. - -**Instance:** https://hackforger.inside.h2os.cloud -**Credentials:** hackforger / admin1234 - -- [ ] **Step 1:** Log in as admin, create org `test-org` if not exists -- [ ] **Step 2:** Create hackathon "Spring Hack 2026" with slug `spring-hack-2026` -- [ ] **Step 3:** Add 2 tracks: "AI Track", "Web3 Track" -- [ ] **Step 4:** Publish hackathon → verify status becomes Open on explore page -- [ ] **Step 5:** Create test user `hacker_eve`, register for hackathon -- [ ] **Step 6:** As admin, approve registration, advance to Hacking -- [ ] **Step 7:** As `hacker_eve`, submit project with demo URL -- [ ] **Step 8:** As admin, advance to Judging -- [ ] **Step 9:** As admin (or judge user), score the submission -- [ ] **Step 10:** As admin, finalize → verify leaderboard shows rank #1 -- [ ] **Step 11:** Check Feed API: `GET /api/v1/hackforger/feed?type=global` — verify events exist -- [ ] **Step 12:** Write results to `docs/tests/e2e/phase1-hackathon-e2e-report.md` - -Report template: -```markdown -# Phase 1 Hackathon E2E Report - -**Date:** YYYY-MM-DD -**Tester:** -**Instance:** https://hackforger.inside.h2os.cloud - -| Step | Description | Result | Notes | -|------|-------------|--------|-------| -| 1 | Create org | PASS/FAIL | | -| 2 | Create hackathon | PASS/FAIL | | -| ... | ... | ... | ... | - -## Feed Events Verified -- [ ] HackathonCreated (30) -- [ ] HackathonRegistered (31) -- [ ] HackathonSubmitted (32) -- [ ] HackathonScored (33) -- [ ] HackathonPhaseChanged (50) -- [ ] HackathonFinalized (51) -``` diff --git a/docs/superpowers/plans/2026-03-29-hackathon-fixes.md b/docs/superpowers/plans/2026-03-29-hackathon-fixes.md deleted file mode 100644 index 2dd22f89ea..0000000000 --- a/docs/superpowers/plans/2026-03-29-hackathon-fixes.md +++ /dev/null @@ -1,764 +0,0 @@ -# Phase 1.1 Hackathon — Org/Repo Model + View + Bug Fixes - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Align hackathon model with Forgejo's Org/Repo primitives (Hackathon→Org, Track→Repo, Register→Org Member, Submit→Fork+PR), redesign detail page, fix dashboard feed bug (#1) and error handling UX. - -**Architecture:** Hackathon creates a real Forgejo Organization; Track creates a real Repository in that Org. Registration adds user to Org. Submission forks the track repo and creates a PR. Extension tables (`hackathon`, `hackathon_track`, `hackathon_submission`) store HackForger-specific metadata (status machine, scores, dates, prizes) via foreign keys to the Forgejo entities. - -**Tech Stack:** Go, XORM, Forgejo service APIs (`organization.CreateOrganization`, `repo_service.CreateRepository`, `repo_service.ForkRepositoryIfNotExists`, `pull_service.NewPullRequest`), Go HTML templates. - -**Key Forgejo APIs:** -- `organization.CreateOrganization(ctx, org, owner)` — creates org -- `organization.AddOrgUser(ctx, orgID, userID)` — adds member -- `repo_service.CreateRepository(ctx, doer, owner, CreateRepoOptions{})` — creates repo -- `repo_service.ForkRepositoryIfNotExists(ctx, doer, owner, ForkRepoOptions{})` — forks repo -- `pull_service.NewPullRequest(ctx, repo, issue, labelIDs, uuids, pr, assigneeIDs)` — creates PR - ---- - -## Task Ordering Rationale - -``` -T1: Bug #1 fix (LEFT JOIN) ──────→ no dependencies, unblocks feed testing -T2: Model migration (add fields) ─→ schema changes, no code dependencies -T3: Hackathon service (auto Org/Repo) → depends on T2 for new fields -T4: Registration service (Org member) → depends on T3 for linked_org_id -T5: Submission service (Fork+PR) ──→ depends on T3 for track repo_id -T6: View page redesign ───────────→ depends on T3-T5 for data to display -T7: Error handling UX ────────────→ independent, polish -T8: Build + E2E ───────────────────→ depends on all above -``` - ---- - -## Chunk 1: Dashboard Feed Bug Fix - -### Task 1: Fix GetFeeds INNER JOIN excluding repo_id=0 events - -**Root cause:** `models/activities/action.go` `GetFeeds()` uses `INNER JOIN repository` — hackathon events with `RepoID=0` are excluded. - -**Files:** -- Modify: `models/activities/action.go` (~line 502) -- Modify: `models/activities/action_list.go` (`loadRepoOwner`) - -- [ ] **Step 1:** In `models/activities/action.go`, find `GetFeeds` function. Change: -```go -Join("INNER", "repository", "`repository`.id = `action`.repo_id") -``` -to: -```go -Join("LEFT", "repository", "`repository`.id = `action`.repo_id") -``` - -- [ ] **Step 2:** In `models/activities/action_list.go`, find `loadRepoOwner` function. The loop accesses `action.Repo.OwnerID` — with LEFT JOIN, `action.Repo` can be nil. Add a nil guard at the start of the loop body: -```go -if action.Repo == nil { - continue -} -``` - -- [ ] **Step 3:** Verify: `go build ./models/activities/...` - -- [ ] **Step 4:** Commit: -```bash -git add models/activities/action.go models/activities/action_list.go -git commit -m "fix: LEFT JOIN in GetFeeds to include repo_id=0 HackForger events" -``` - ---- - -## Chunk 2: Model Schema Changes - -### Task 2: Add LinkedOrgID to Hackathon + OrgID to Registration + PRID to Submission - -**Files:** -- Modify: `models/hackforger/hackathon.go` — add `LinkedOrgID int64` -- Modify: `models/hackforger/hackathon_registration.go` — add `OrgID int64` -- Modify: `models/hackforger/hackathon_submission.go` — add `PRID int64`, `PullIndex int64` -- Create: `models/forgejo_migrations/v14f_hackathon-org-repo-fields.go` - -- [ ] **Step 1:** Add fields to model structs. - -`hackathon.go` — add after `TemplateRepoID`: -```go -LinkedOrgID int64 `xorm:"INDEX"` // the auto-created Forgejo Organization for this hackathon -``` - -`hackathon_registration.go` — add after `UserID`: -```go -OrgID int64 `xorm:"INDEX"` // 0=solo, >0=team via this org -``` - -`hackathon_submission.go` — add after `RepoID`: -```go -ForkRepoID int64 `xorm:"INDEX"` // participant's fork of the track repo -PRID int64 `xorm:"INDEX"` // pull request ID (action table primary key) -PullIndex int64 `xorm:""` // pull request index within the track repo -``` - -- [ ] **Step 2:** Write migration. - -File: `models/forgejo_migrations/v14f_hackathon-org-repo-fields.go` - -```go -package forgejo_migrations - -import "xorm.io/xorm" - -func init() { - registerMigration(&Migration{ - Description: "add org/repo integration fields to hackathon tables", - Upgrade: addHackathonOrgRepoFields, - }) -} - -type v14fHackathon struct { - LinkedOrgID int64 `xorm:"INDEX DEFAULT 0"` -} -func (v14fHackathon) TableName() string { return "hackathon" } - -type v14fHackathonRegistration struct { - OrgID int64 `xorm:"INDEX DEFAULT 0"` -} -func (v14fHackathonRegistration) TableName() string { return "hackathon_registration" } - -type v14fHackathonSubmission struct { - ForkRepoID int64 `xorm:"INDEX DEFAULT 0"` - PRID int64 `xorm:"INDEX DEFAULT 0"` - PullIndex int64 `xorm:"DEFAULT 0"` -} -func (v14fHackathonSubmission) TableName() string { return "hackathon_submission" } - -func addHackathonOrgRepoFields(x *xorm.Engine) error { - if err := x.Sync(new(v14fHackathon)); err != nil { - return err - } - if err := x.Sync(new(v14fHackathonRegistration)); err != nil { - return err - } - return x.Sync(new(v14fHackathonSubmission)) -} -``` - -- [ ] **Step 3:** Update `CreateRegistration` uniqueness logic for Org support. - -In `hackathon_registration.go`, replace `CreateRegistration`: - -```go -func CreateRegistration(ctx context.Context, r *HackathonRegistration) error { - existing := &HackathonRegistration{} - has, err := db.GetEngine(ctx).Where("hackathon_id = ? AND user_id = ?", r.HackathonID, r.UserID).Get(existing) - if err != nil { - return err - } - if has { - if r.OrgID > 0 && existing.OrgID == 0 { - // Solo → Team upgrade - existing.OrgID = r.OrgID - existing.TeamName = r.TeamName - _, err := db.GetEngine(ctx).ID(existing.ID).Cols("org_id", "team_name").Update(existing) - return err - } - return ErrDuplicateRegistration{HackathonID: r.HackathonID, UserID: r.UserID} - } - return db.Insert(ctx, r) -} -``` - -- [ ] **Step 4:** Verify: `go build ./models/hackforger/... ./models/forgejo_migrations/...` - -- [ ] **Step 5:** Commit: -```bash -git add models/hackforger/hackathon.go models/hackforger/hackathon_registration.go models/hackforger/hackathon_submission.go models/forgejo_migrations/v14f_hackathon-org-repo-fields.go -git commit -m "feat(hackforger): add Org/Repo integration fields to hackathon models - -Hackathon.LinkedOrgID → auto-created Forgejo Org -HackathonRegistration.OrgID → solo(0) or team via org -HackathonSubmission.ForkRepoID/PRID/PullIndex → Fork+PR submission" -``` - ---- - -## Chunk 3: Hackathon Service — Auto Org/Repo Creation - -### Task 3: Create Org on hackathon creation, Repo on track creation - -**Files:** -- Modify: `services/hackforger/hackathon.go` -- Modify: `routers/web/hackforger/hackathon.go` (import changes) - -- [ ] **Step 1:** Update `CreateHackathon` service to auto-create Org. - -In `services/hackforger/hackathon.go`, modify `CreateHackathon`: - -```go -import ( - // add these imports - organization_model "forgejo.org/models/organization" - user_model "forgejo.org/models/user" -) - -func CreateHackathon(ctx context.Context, doer *user_model.User, h *hackforger_model.Hackathon) error { - // 1. Create a Forgejo Organization for this hackathon - org := &organization_model.Organization{ - Name: h.Slug, - FullName: h.Name, - Visibility: structs.VisibleTypePublic, - } - if err := organization_model.CreateOrganization(ctx, org, doer); err != nil { - // If org name collision, try with suffix - if user_model.IsErrUserAlreadyExist(err) { - org.Name = h.Slug + "-hackathon" - if err := organization_model.CreateOrganization(ctx, org, doer); err != nil { - return fmt.Errorf("create hackathon org: %w", err) - } - } else { - return fmt.Errorf("create hackathon org: %w", err) - } - } - - // 2. Create hackathon record with linked org - h.LinkedOrgID = org.ID - h.OrgID = org.ID // also set OrgID for feed audience - h.OwnerID = doer.ID - if err := hackforger_model.CreateHackathon(ctx, h); err != nil { - return err - } - - // 3. Publish feed event - _ = PublishHackforgerAction(ctx, &HackforgerActionOpts{ - ActUserID: doer.ID, - OpType: hackforger_model.ActionHackathonCreated, - AudienceType: AudienceGlobal, - Content: hackforger_model.HackforgerActionContent{ - EntityType: "hackathon", EntityID: h.ID, EntityName: h.Name, EntitySlug: h.Slug, - }, - }) - return nil -} -``` - -Note: The function signature changes from `(ctx context.Context, doerID int64, h *Hackathon)` to `(ctx context.Context, doer *user_model.User, h *Hackathon)` — need to update all callers to pass `ctx.Doer` instead of `ctx.Doer.ID`. - -Also need `structs "forgejo.org/modules/structs"` for `VisibleTypePublic`. - -- [ ] **Step 2:** Add `CreateTrackWithRepo` service function. - -New function in `services/hackforger/hackathon.go`: - -```go -import ( - repo_service "forgejo.org/services/repository" -) - -// CreateTrackWithRepo creates a track and its associated repository in the hackathon org. -func CreateTrackWithRepo(ctx context.Context, doer *user_model.User, h *hackforger_model.Hackathon, track *hackforger_model.HackathonTrack) error { - // Get the hackathon's org (as a user for repo creation) - orgUser, err := user_model.GetUserByID(ctx, h.LinkedOrgID) - if err != nil { - return fmt.Errorf("get hackathon org: %w", err) - } - - // Create repo in the hackathon org - repoName := strings.ToLower(strings.ReplaceAll(track.Name, " ", "-")) - repo, err := repo_service.CreateRepository(ctx, doer, orgUser, repo_service.CreateRepoOptions{ - Name: repoName, - Description: track.Description, - AutoInit: true, - DefaultBranch: "main", - IsPrivate: false, - }) - if err != nil { - return fmt.Errorf("create track repo: %w", err) - } - - track.RepoID = repo.ID - return hackforger_model.CreateTrack(ctx, track) -} -``` - -- [ ] **Step 3:** Update web handler callers. - -In `routers/web/hackforger/hackathon.go`: - -Update `NewHackathonPost` — change `hackforger_service.CreateHackathon(ctx, ctx.Doer.ID, h)` to `hackforger_service.CreateHackathon(ctx, ctx.Doer, h)`. - -Update `ManageTrackPost` — change direct `hackforger_model.CreateTrack(ctx, t)` to `hackforger_service.CreateTrackWithRepo(ctx, ctx.Doer, h, t)`. - -Update all other callers of `CreateHackathon` (state transition functions that pass `doerID`) — they need to accept `*user_model.User` or the caller needs to resolve the user. Actually, for state transitions the doer is just for feed events — keep those as `doerID int64`. Only `CreateHackathon` needs the full User. - -- [ ] **Step 4:** Update API handler callers similarly. - -In `routers/api/v1/hackforger/hackathon.go`: change `hackforger_service.CreateHackathon(ctx, ctx.Doer.ID, h)` to `hackforger_service.CreateHackathon(ctx, ctx.Doer, h)`. - -- [ ] **Step 5:** Verify: `go build ./services/hackforger/... ./routers/...` - -- [ ] **Step 6:** Commit: -```bash -git add services/hackforger/hackathon.go routers/web/hackforger/hackathon.go routers/api/v1/hackforger/hackathon.go -git commit -m "feat(hackforger): auto-create Org on hackathon creation, Repo on track creation - -CreateHackathon now creates a Forgejo Organization (slug as org name). -CreateTrackWithRepo creates a Repository in that Org for each track." -``` - ---- - -## Chunk 4: Registration = Org Membership - -### Task 4: Registration adds user to hackathon Org - -**Files:** -- Modify: `routers/web/hackforger/hackathon.go` (`RegisterPost`, `ViewHackathon`) -- Modify: `routers/api/v1/hackforger/hackathon_registration.go` - -- [ ] **Step 1:** Update `RegisterPost` web handler. - -```go -import ( - organization_model "forgejo.org/models/organization" -) - -func RegisterPost(ctx *context.Context) { - h := loadHackathon(ctx) - if h == nil { return } - if h.Status != hackforger_model.HackathonStatusOpen { - ctx.Flash.Error(ctx.Tr("hackforger.hackathon.error.invalid_phase")) - ctx.Redirect("/hackathon/" + h.Slug) - return - } - - orgID, _ := strconv.ParseInt(ctx.FormString("org_id"), 10, 64) - teamName := ctx.FormString("team_name") - - // Determine team name - if orgID > 0 { - // Verify org membership - isMember, err := organization_model.IsOrganizationMember(ctx, orgID, ctx.Doer.ID) - if err != nil || !isMember { - ctx.Flash.Error(ctx.Tr("hackforger.hackathon.error.not_org_member")) - ctx.Redirect("/hackathon/" + h.Slug) - return - } - if org, err := organization_model.GetOrgByID(ctx, orgID); err == nil { - teamName = org.Name - } - } else if teamName == "" { - teamName = ctx.Doer.Name - } - - r := &hackforger_model.HackathonRegistration{ - HackathonID: h.ID, - UserID: ctx.Doer.ID, - OrgID: orgID, - TeamName: teamName, - } - if err := hackforger_model.CreateRegistration(ctx, r); err != nil { - if hackforger_model.IsErrDuplicateRegistration(err) { - ctx.Flash.Error(ctx.Tr("hackforger.hackathon.register.already_registered")) - } else { - ctx.Flash.Error(err.Error()) - } - ctx.Redirect("/hackathon/" + h.Slug) - return - } - - // Add user to hackathon org as member - if h.LinkedOrgID > 0 { - _ = organization_model.AddOrgUser(ctx, h.LinkedOrgID, ctx.Doer.ID) - } - - ctx.Flash.Success(ctx.Tr("hackforger.hackathon.register.success")) - ctx.Redirect("/hackathon/" + h.Slug) -} -``` - -- [ ] **Step 2:** Update `ViewHackathon` to pass user's orgs for registration dropdown. - -Add to `ViewHackathon` (before `ctx.HTML`): -```go -if ctx.Doer != nil { - orgs, _ := organization_model.GetUserOrgsList(ctx, ctx.Doer) - ctx.Data["UserOrgs"] = orgs -} -``` - -- [ ] **Step 3:** Add i18n keys. - -English: -```ini -hackathon.register.mode = Participate as -hackathon.register.solo = Individual -hackathon.register.team = Team -hackathon.register.display_name = Display Name -hackathon.register.display_name_hint = Optional — defaults to your username or org name -hackathon.error.not_org_member = You are not a member of this organization. -``` - -Chinese: -```ini -hackathon.register.mode = 参赛方式 -hackathon.register.solo = 个人参赛 -hackathon.register.team = 团队参赛 -hackathon.register.display_name = 显示名称 -hackathon.register.display_name_hint = 可选 — 默认使用用户名或组织名 -hackathon.error.not_org_member = 你不是该组织的成员。 -``` - -- [ ] **Step 4:** Verify and commit: -```bash -git add routers/web/hackforger/hackathon.go options/locale/ -git commit -m "feat(hackforger): registration adds user to hackathon Org - -Solo or team registration. Solo→Team auto-upgrade. User added to -hackathon's Forgejo Organization as member on registration." -``` - ---- - -## Chunk 5: Submission = Fork + PR - -### Task 5: Submission forks track repo and creates PR - -**Files:** -- Modify: `routers/web/hackforger/hackathon.go` (`SubmitPost`) -- Modify: `services/hackforger/hackathon.go` (new `CreateSubmission` service) - -- [ ] **Step 1:** Create `CreateSubmission` service function. - -In `services/hackforger/hackathon.go`: - -```go -import ( - issues_model "forgejo.org/models/issues" - repo_model "forgejo.org/models/repo" - repo_service "forgejo.org/services/repository" - pull_service "forgejo.org/services/pull" -) - -// CreateSubmission forks the track repo to the user's space and creates a PR. -func CreateSubmission(ctx context.Context, doer *user_model.User, h *hackforger_model.Hackathon, sub *hackforger_model.HackathonSubmission) error { - // 1. Get the track repo - if sub.TrackID == 0 { - // No track selected — just create submission record without fork/PR - return hackforger_model.CreateSubmission(ctx, sub) - } - - track, err := hackforger_model.GetTrackByID(ctx, sub.TrackID) - if err != nil || track.RepoID == 0 { - // Track has no associated repo — create submission without fork/PR - return hackforger_model.CreateSubmission(ctx, sub) - } - - baseRepo, err := repo_model.GetRepositoryByID(ctx, track.RepoID) - if err != nil { - return fmt.Errorf("get track repo: %w", err) - } - - // 2. Fork the track repo to user's space - fork, err := repo_service.ForkRepositoryIfNotExists(ctx, doer, doer, repo_service.ForkRepoOptions{ - BaseRepo: baseRepo, - Name: baseRepo.Name + "-" + doer.LowerName, - Description: sub.Title, - }) - if err != nil { - // If fork already exists, find it - if repo_service.IsErrForkAlreadyExist(err) { - fork, err = repo_model.GetUserFork(ctx, baseRepo.ID, doer.ID) - if err != nil { - return fmt.Errorf("get existing fork: %w", err) - } - } else { - return fmt.Errorf("fork track repo: %w", err) - } - } - sub.ForkRepoID = fork.ID - sub.RepoID = fork.ID - - // 3. Create a PR from fork to track repo - issue := &issues_model.Issue{ - RepoID: baseRepo.ID, - Title: sub.Title, - Content: sub.Description, - PosterID: doer.ID, - } - pr := &issues_model.PullRequest{ - HeadRepoID: fork.ID, - BaseRepoID: baseRepo.ID, - HeadBranch: fork.DefaultBranch, - BaseBranch: baseRepo.DefaultBranch, - Type: issues_model.PullRequestGitea, - } - if err := pull_service.NewPullRequest(ctx, baseRepo, issue, nil, nil, pr, nil); err != nil { - // PR creation may fail if no commits differ — that's ok, just save submission - log.Warn("CreateSubmission: PR creation failed (may be no diff): %v", err) - } else { - sub.PRID = pr.ID - sub.PullIndex = issue.Index - } - - // 4. Save submission record - if err := hackforger_model.CreateSubmission(ctx, sub); err != nil { - return err - } - - // 5. Feed event - _ = PublishHackforgerAction(ctx, &HackforgerActionOpts{ - ActUserID: doer.ID, - OpType: hackforger_model.ActionHackathonSubmitted, - AudienceType: AudienceFollowers, - RepoID: baseRepo.ID, - Content: hackforger_model.HackforgerActionContent{ - EntityType: "hackathon", EntityID: h.ID, EntityName: h.Name, EntitySlug: h.Slug, - Extra: map[string]any{"submission_id": sub.ID, "title": sub.Title}, - }, - }) - return nil -} -``` - -- [ ] **Step 2:** Update `SubmitPost` web handler to call the service. - -Replace direct `hackforger_model.CreateSubmission(ctx, s)` with: -```go -if err := hackforger_service.CreateSubmission(ctx, ctx.Doer, h, s); err != nil { - ctx.Flash.Error(err.Error()) - ctx.Redirect("/hackathon/" + h.Slug + "/submit") - return -} -``` - -Remove the inline feed event publishing in the web handler (now in the service). - -- [ ] **Step 3:** Also update the API handler similarly. - -- [ ] **Step 4:** Verify and commit: -```bash -git add services/hackforger/hackathon.go routers/web/hackforger/hackathon.go routers/api/v1/hackforger/hackathon_submission.go -git commit -m "feat(hackforger): submission forks track repo and creates PR - -CreateSubmission now forks the track repo to user's space and creates -a PR. Submission record stores fork_repo_id, pr_id, and pull_index." -``` - ---- - -## Chunk 6: View Page Redesign - -### Task 6: Redesign hackathon detail page - -**Files:** -- Modify: `templates/hackforger/hackathon/view.tmpl` -- Modify: `routers/web/hackforger/hackathon.go` (`ViewHackathon`) -- Modify: `options/locale/locale_en-US.ini`, `locale_zh-CN.ini` - -- [ ] **Step 1:** Update `ViewHackathon` handler to load all data. - -```go -func ViewHackathon(ctx *context.Context) { - h := loadHackathon(ctx) - if h == nil { return } - ctx.Data["Title"] = h.Name - ctx.Data["Hackathon"] = h - ctx.Data["StatusLabel"] = hackforger_service.HackathonStatusLabel(h.Status) - - // Owner - if h.OwnerID > 0 { - if owner, err := user_model.GetUserByID(ctx, h.OwnerID); err == nil { - ctx.Data["Owner"] = owner - } - } - - // Linked Org - if h.LinkedOrgID > 0 { - if org, err := user_model.GetUserByID(ctx, h.LinkedOrgID); err == nil { - ctx.Data["LinkedOrg"] = org - } - } - - // Tracks (with repo info) - tracks, _ := hackforger_model.ListTracksByHackathon(ctx, h.ID) - ctx.Data["Tracks"] = tracks - - // Registrations - regs, regCount, _ := hackforger_model.ListRegistrations(ctx, hackforger_model.ListRegistrationsOptions{HackathonID: h.ID}) - ctx.Data["Registrations"] = regs - ctx.Data["RegistrationCount"] = regCount - - // Submissions (with PR links) - subs, _, _ := hackforger_model.ListSubmissions(ctx, hackforger_model.ListSubmissionsOptions{HackathonID: h.ID}) - ctx.Data["Submissions"] = subs - - // Judges - judges, _ := hackforger_model.ListJudges(ctx, h.ID) - ctx.Data["JudgeCount"] = len(judges) - - // Current user - if ctx.Doer != nil { - _, err := hackforger_model.GetRegistration(ctx, h.ID, ctx.Doer.ID) - ctx.Data["IsRegistered"] = err == nil - ctx.Data["SignedUserID"] = ctx.Doer.ID - ctx.Data["IsOwner"] = ctx.Doer.ID == h.OwnerID - isJudge, _ := hackforger_model.IsJudge(ctx, h.ID, ctx.Doer.ID) - ctx.Data["IsJudge"] = isJudge - orgs, _ := organization_model.GetUserOrgsList(ctx, ctx.Doer) - ctx.Data["UserOrgs"] = orgs - } - - ctx.HTML(http.StatusOK, tplView) -} -``` - -- [ ] **Step 2:** Rewrite `view.tmpl`. - -Full template covering: header (name + status + organizer + org link + manage/judge buttons), timeline, description, prizes, tracks (with repo links), registration form (solo/team), registered indicator, participants list, submit button, submissions table (with PR links), leaderboard link. - -Key additions vs current: -- **Org link**: `` -- **Track repo links**: `{{if .RepoID}}{{svg "octicon-repo"}} Code{{end}}` -- **Submission PR links**: `{{if .PullIndex}}PR #{{.PullIndex}}{{end}}` -- **Timeline**: dates from `RegistrationEnd`, `HackingEnd`, `JudgingEnd` -- **Org registration dropdown**: solo or select from user's orgs - -(Template content is the full redesign from the previous plan version — see the `view.tmpl` in Chunk 3 of the old plan, enhanced with org/repo links.) - -- [ ] **Step 3:** Add i18n keys (both locales): - -```ini -; English -hackathon.view.organizer = Organizer -hackathon.view.org = Organization -hackathon.view.timeline = Timeline -hackathon.view.registration_end = Registration Ends -hackathon.view.hacking_end = Hacking Ends -hackathon.view.judging_end = Judging Ends -hackathon.view.prizes = Prizes -hackathon.view.participants = Participants -hackathon.view.submissions = Submissions -hackathon.view.pending = Pending -hackathon.view.approved = Approved -hackathon.view.rejected = Rejected -hackathon.view.pr = Pull Request -hackathon.view.code = Code - -; Chinese -hackathon.view.organizer = 组织者 -hackathon.view.org = 组织 -hackathon.view.timeline = 时间线 -hackathon.view.registration_end = 报名截止 -hackathon.view.hacking_end = 开发截止 -hackathon.view.judging_end = 评审截止 -hackathon.view.prizes = 奖品 -hackathon.view.participants = 参与者 -hackathon.view.submissions = 提交作品 -hackathon.view.pending = 待审核 -hackathon.view.approved = 已通过 -hackathon.view.rejected = 已拒绝 -hackathon.view.pr = 拉取请求 -hackathon.view.code = 代码 -``` - -- [ ] **Step 4:** Verify and commit: -```bash -git add templates/hackforger/hackathon/view.tmpl routers/web/hackforger/hackathon.go options/locale/ -git commit -m "feat(hackforger): redesign hackathon detail page with Org/Repo links - -Timeline, organizer info, org link, track repo links, org-based -registration form, participants list, submission PR links, -phase-appropriate CTAs." -``` - ---- - -## Chunk 7: Error Handling UX - -### Task 7: Flash messages for all error paths - -**Files:** -- Modify: `routers/web/hackforger/hackathon.go` - -- [ ] **Step 1:** Fix `ManagePhasePost` — always redirect with flash, never return raw HTTP error. - -Re-fetch hackathon by ID before state transition (in case concurrent update changed status): -```go -h, err := hackforger_model.GetHackathonByID(ctx, h.ID) -``` - -Replace `ctx.NotFound(...)` with `ctx.Flash.Error(...) + ctx.Redirect(...)`. - -- [ ] **Step 2:** Fix `SubmitForm` — check phase on GET and redirect with flash if wrong phase. - -- [ ] **Step 3:** Verify and commit: -```bash -git add routers/web/hackforger/hackathon.go -git commit -m "fix(hackforger): flash messages for all error responses" -``` - ---- - -## Chunk 8: Build + E2E - -### Task 8: Full rebuild, test data cleanup, E2E verification - -- [ ] **Step 1:** Kill server, rebuild, restart: -```bash -kill $(lsof -t -i :3000) 2>/dev/null -TAGS="bindata sqlite sqlite_unlock_notify" make build -rm -f /Users/h2oslabs/Workspace/hackforger/data/queues/common/LOCK -./gitea web -``` - -- [ ] **Step 2:** Clean test data: -```sql -DELETE FROM hackathon_judge_score; -DELETE FROM hackathon_judge; -DELETE FROM hackathon_submission; -DELETE FROM hackathon_registration; -DELETE FROM hackathon_track; -DELETE FROM hackathon; -DELETE FROM action WHERE op_type >= 30 AND op_type <= 60; -``` - -- [ ] **Step 3:** E2E test — verify: -1. Creating hackathon also creates an Org (check `/spring-hack-2026` org page) -2. Adding track creates a Repo in the org (check `/spring-hack-2026/ai-track`) -3. Registration adds user to org (check org member list) -4. Registration form shows solo/team dropdown -5. Submission forks track repo and creates PR -6. Detail page shows timeline, participants, tracks with repo links, submissions with PR links -7. Dashboard feed shows hackathon events with rocket icon and Chinese text -8. All manage buttons work in browser (CrossOriginProtection fix) -9. Error cases show flash messages - -- [ ] **Step 4:** Write E2E report, commit and push. - ---- - -## Manual E2E Prompt Addendum - -After all tasks are implemented, add these verification steps to the E2E prompt: - -``` -### Org/Repo Integration Verification - -1. After creating hackathon "Spring Hack 2026": - - Navigate to /spring-hack-2026 → should show the Org page - - Org owner should be the admin user - -2. After adding "AI Track": - - Navigate to /spring-hack-2026/ai-track → should show a Repo page - - Repo should have auto-init commit on main branch - -3. After registering as hacker_eve: - - Navigate to /spring-hack-2026 (org page) → Members should include hacker_eve - -4. After submitting project: - - hacker_eve should have a fork: /hacker_eve/ai-track-hacker_eve - - A PR should exist on /spring-hack-2026/ai-track/pulls - -5. On hackathon detail page: - - "Organization" link goes to org page - - Each track has a "Code" link to repo - - Each submission has a "PR #N" link -``` diff --git a/docs/superpowers/plans/2026-03-30-credits-integration-fulfill.md b/docs/superpowers/plans/2026-03-30-credits-integration-fulfill.md deleted file mode 100644 index e435754573..0000000000 --- a/docs/superpowers/plans/2026-03-30-credits-integration-fulfill.md +++ /dev/null @@ -1,2544 +0,0 @@ -# Credits Integration + Fulfill Enhancement — Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Complete the Credits system by wiring Hackathon prize distribution, enhancing the fulfillment workflow (delivery details, batch fulfill, auto-fulfill key pool, notifications), and finishing zh-CN i18n. - -**Architecture:** Layered model→service→router changes. Migration adds columns + new table. Hackathon credits use per-track ranking (independent of global rank). Auto-fulfill uses a key pool table with two-step SELECT+UPDATE for SQLite compatibility. Order notifications use a new `AudienceDirectUser` audience type (isolated commit). - -**Tech Stack:** Go, XORM, go-chi, Go HTML templates, Fomantic UI, i18n via locale INI files - -**Spec:** `docs/superpowers/specs/2026-03-30-credits-integration-fulfill-design.md` - ---- - -## ⚠ Errata (Post-Review Corrections) - -The plan was reviewed by 3 subagents after initial writing. Below are corrections that MUST be applied during implementation. Implementers: read this section first and apply corrections to the relevant task code. - -### E1. Chunk 1 already implemented — fixtures differ from plan - -Chunk 1 (Tasks 1-7) was implemented before this review. The actual committed fixtures differ from the plan's code blocks: - -- **hackathon.yml**: status=3 (Judging), not 4 as plan says (plan had wrong enum: 3=Judging, 4=Finished) -- **hackathon_track.yml**: Tiered track uses ratios `60/30/10`, not `50/30/20` -- **hackathon_submission.yml**: Users are reused across tracks: track 1 = {user 2, user 4}, track 2 = {user 2, user 4, user 5}, track 3 = {user 2, user 4} -- **redeem_option.yml**: Option 2 has cost=2000, stock=-1 (not cost=100, stock=2) -- **redeem_order.yml**: Order 2 has user_id=4/option_id=2, Order 3 has user_id=5/option_id=1 -- **ClaimKey**: ForUpdate() was missing; fixed in commit 906306a162 - -**Action for Task 8**: Tests must use actual fixture user IDs and ratios. See corrected tests below in the errata for Task 8. - -### E2. Task 8: Corrected test code matching actual fixtures - -Replace the plan's Task 8 test code with: - -```go -func TestDistributeHackathonCredits_WinnerTakesAll(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - err := distributeHackathonCredits(db.DefaultContext, 1) - require.NoError(t, err) - - // Track 1: winner_takes_all, 1000 credits - // Fixtures: user 2 (score 95.0) rank 1, user 4 (score 80.0) rank 2 - // Only user 2 should get track 1 deposit - txns2, _, _ := ListTransactions(db.DefaultContext, 2, db.ListOptions{Page: 1, PageSize: 50}) - var track1u2 int64 - for _, tx := range txns2 { - if tx.Reference == "hackathon:1/track:1:rank:1" { - track1u2 += tx.Amount - } - } - assert.Equal(t, int64(1000), track1u2) - - // User 4 should NOT get credits from track 1 (rank 2) - txns4, _, _ := ListTransactions(db.DefaultContext, 4, db.ListOptions{Page: 1, PageSize: 50}) - for _, tx := range txns4 { - assert.NotEqual(t, "hackathon:1/track:1:rank:2", tx.Reference) - } -} - -func TestDistributeHackathonCredits_Tiered(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - err := distributeHackathonCredits(db.DefaultContext, 1) - require.NoError(t, err) - - // Track 2: tiered [60/30/10] with 1000 credits - // Fixtures: user 2 (90.0) rank 1, user 4 (85.0) rank 2, user 5 (70.0) rank 3 - txns2, _, _ := ListTransactions(db.DefaultContext, 2, db.ListOptions{Page: 1, PageSize: 50}) - var t2u2 int64 - for _, tx := range txns2 { - if tx.Reference == "hackathon:1/track:2:rank:1" { - t2u2 += tx.Amount - } - } - assert.Equal(t, int64(600), t2u2) - - txns4, _, _ := ListTransactions(db.DefaultContext, 4, db.ListOptions{Page: 1, PageSize: 50}) - var t2u4 int64 - for _, tx := range txns4 { - if tx.Reference == "hackathon:1/track:2:rank:2" { - t2u4 += tx.Amount - } - } - assert.Equal(t, int64(300), t2u4) - - txns5, _, _ := ListTransactions(db.DefaultContext, 5, db.ListOptions{Page: 1, PageSize: 50}) - var t2u5 int64 - for _, tx := range txns5 { - if tx.Reference == "hackathon:1/track:2:rank:3" { - t2u5 += tx.Amount - } - } - assert.Equal(t, int64(100), t2u5) -} - -func TestDistributeHackathonCredits_Equal(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - err := distributeHackathonCredits(db.DefaultContext, 1) - require.NoError(t, err) - - // Track 3: equal, 1000 credits, 2 users (user 2 score 88.0, user 4 score 88.0) - txns2, _, _ := ListTransactions(db.DefaultContext, 2, db.ListOptions{Page: 1, PageSize: 50}) - var t3u2 int64 - for _, tx := range txns2 { - if tx.Reference == "hackathon:1/track:3:rank:1" { - t3u2 += tx.Amount - } - } - assert.Equal(t, int64(500), t3u2) - - txns4, _, _ := ListTransactions(db.DefaultContext, 4, db.ListOptions{Page: 1, PageSize: 50}) - var t3u4 int64 - for _, tx := range txns4 { - if tx.Reference == "hackathon:1/track:3:rank:2" { - t3u4 += tx.Amount - } - } - assert.Equal(t, int64(500), t3u4) -} - -func TestDistributeHackathonCredits_EqualRemainder(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - track := unittest.AssertExistsAndLoadBean(t, &hackforger_model.HackathonTrack{ID: 3}) - track.PrizeCredits = 999 - require.NoError(t, hackforger_model.UpdateTrack(db.DefaultContext, track)) - - err := distributeHackathonCredits(db.DefaultContext, 1) - require.NoError(t, err) - - // 999 / 2 = 499, remainder 1 → rank 1 (user 2) gets 500 - txns2, _, _ := ListTransactions(db.DefaultContext, 2, db.ListOptions{Page: 1, PageSize: 50}) - var t3u2 int64 - for _, tx := range txns2 { - if tx.Reference == "hackathon:1/track:3:rank:1" { - t3u2 += tx.Amount - } - } - assert.Equal(t, int64(500), t3u2) - - txns4, _, _ := ListTransactions(db.DefaultContext, 4, db.ListOptions{Page: 1, PageSize: 50}) - var t3u4 int64 - for _, tx := range txns4 { - if tx.Reference == "hackathon:1/track:3:rank:2" { - t3u4 += tx.Amount - } - } - assert.Equal(t, int64(499), t3u4) -} -``` - -### E3. Tasks 10/14/15: `ctx.PathParamInt64` does not exist - -Forgejo uses `ctx.ParamsInt64(":id")` (with colon prefix), NOT `ctx.PathParamInt64("id")`. Fix all occurrences in: -- Task 14 web handlers: `ctx.ParamsInt64(":id")` for option ID, `ctx.ParamsInt64(":oid")` for order ID -- Task 15 API handlers: same pattern - -### E4. Task 10: Complete CancelOrder refactoring - -The plan's note about CancelOrder is incomplete. Here is the full replacement: - -```go -func CancelOrder(ctx context.Context, admin *user_model.User, orderID int64) error { - if !admin.IsAdmin { - return ErrNotAdmin{UserID: admin.ID} - } - - var order *hackforger_model.RedeemOrder - var optName string - - err := db.WithTx(ctx, func(ctx context.Context) error { - var err error - order, err = hackforger_model.GetRedeemOrderByID(ctx, orderID) - if err != nil { - return err - } - if order.Status != hackforger_model.OrderStatusPending { - return ErrOrderNotPending{OrderID: orderID, Status: order.Status} - } - - order.Status = hackforger_model.OrderStatusCancelled - if err := hackforger_model.UpdateRedeemOrder(ctx, order); err != nil { - return err - } - - acct, err := GetOrCreateCreditAccount(ctx, order.UserID) - if err != nil { - return err - } - acct.Balance += order.Cost - if _, err := db.GetEngine(ctx).ID(acct.ID).Cols("balance").Update(acct); err != nil { - return err - } - - tx := &hackforger_model.CreditTransaction{ - UserID: order.UserID, - Type: hackforger_model.TransactionTypeRefund, - Amount: order.Cost, - Balance: acct.Balance, - Reference: fmt.Sprintf("order_%d_refund", orderID), - Note: "Order cancelled and refunded", - } - _, err = db.GetEngine(ctx).Insert(tx) - - if opt, _ := hackforger_model.GetRedeemOptionByID(ctx, order.OptionID); opt != nil { - optName = opt.Name - } - return err - }) - if err != nil { - return err - } - - notifyOrderStatusChange(ctx, order, admin.ID, hackforger_model.ActionOrderCancelled, optName) - return nil -} -``` - -### E5. Task 10 Step 6: Web handler must read form delivery fields - -The plan incorrectly passes `""` for delivery fields. The web handler should read form values: - -```go -note := ctx.Req.FormValue("note") -deliveryType := ctx.Req.FormValue("delivery_type") -deliveryValue := ctx.Req.FormValue("delivery_value") -hackforger_service.FulfillOrder(ctx, ctx.Doer, orderID, note, deliveryType, deliveryValue) -``` - -### E6. Task 10: Update existing TestFulfillOrder - -The existing test at `services/hackforger/credits_test.go` calls FulfillOrder with 4 args. Update to 6: - -```go -hackforger_service.FulfillOrder(db.DefaultContext, admin, 1, "Fulfilled by admin", "", "") -``` - -### E7. Task 11/13: Redeem() variable hoisting for option - -Declare `var capturedOption *hackforger_model.RedeemOption` before `db.WithTx`. Inside the tx, after `db.GetEngine(ctx).Get(option)`, assign `capturedOption = option`. After the tx block, use `capturedOption.FulfillMode` for the stock sync check. - -### E8. Task 14: Use base.TplName constant and http.StatusOK - -Add constant: `tplAdminRedeemOptionKeys base.TplName = "hackforger/credits/admin/keys"` -Use: `ctx.HTML(http.StatusOK, tplAdminRedeemOptionKeys)` instead of `ctx.HTML(200, "...")` - -### E9. Task 18: Do not show err.Error() to users - -Replace `ctx.Flash.Error(err.Error())` with: -```go -if hackforger_model.IsErrInvalidDistRatios(err) { - ctx.Flash.Error(ctx.Tr("hackforger.hackathon.invalid_ratios")) -} else { - ctx.Flash.Error(ctx.Tr("hackforger.hackathon.invalid_ratios")) -} -``` - -### E10. Task 19: i18n key format — strip `hackforger.` prefix - -Keys under `[hackforger]` section must NOT include the section name. The template `ctx.Locale.Tr "hackforger.credits.admin.key_pool"` resolves to section `[hackforger]` key `credits.admin.key_pool`. - -**Wrong:** `hackforger.credits.admin.key_pool = Key Pool` -**Correct:** `credits.admin.key_pool = Key Pool` - -Strip `hackforger.` prefix from ALL keys in Task 19 Steps 1-2. - -### E11. Task 19: Add feed action locale keys for types 58/59 - -The feed rendering uses keys from a different section (not `[hackforger]`). Add near the other `hackforger_xxx` action keys in the locale files: - -**en-US** (near line 3505): -```ini -hackforger_order_fulfilled = fulfilled a redeem order -hackforger_order_cancelled = cancelled a redeem order -``` - -**zh-CN** (same location): -```ini -hackforger_order_fulfilled = 完成了一个兑换订单 -hackforger_order_cancelled = 取消了一个兑换订单 -``` - -Also add rendering branches in `templates/user/dashboard/feeds.tmpl` for `"hackforger_order_fulfilled"` and `"hackforger_order_cancelled"`. - -### E12. Task 20: Add full Prerequisites section to E2E prompt - -Follow the pattern from existing E2E prompts (e.g., `phase1-grant-e2e.md`) — include server startup sequence, app.ini copy, test accounts, and data cleanup instructions. - ---- - -## Chunk 1: Migration + Model Layer - -### Task 1: Database Migration - -**Files:** -- Create: `models/forgejo_migrations/v14g_credits-fulfill-hackathon-prizes.go` - -- [ ] **Step 1: Write the migration file** - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package forgejo_migrations - -func init() { - registerMigration(&Migration{ - Description: "add credits fulfill and hackathon prize distribution fields", - Upgrade: addCreditsFulfillHackathonPrizes, - }) -} - -// v14g structs for migration — only include columns being added/created. - -type v14gHackathonTrack struct { - PrizeDistMode string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'winner_takes_all'"` - PrizeDistRatios string `xorm:"TEXT NOT NULL DEFAULT ''"` -} - -func (v14gHackathonTrack) TableName() string { return "hackathon_track" } - -type v14gRedeemOption struct { - FulfillMode string `xorm:"VARCHAR(16) NOT NULL DEFAULT 'manual'"` -} - -func (v14gRedeemOption) TableName() string { return "redeem_option" } - -type v14gRedeemOrder struct { - DeliveryType string `xorm:"VARCHAR(32) NOT NULL DEFAULT ''"` - DeliveryValue string `xorm:"TEXT NOT NULL DEFAULT ''"` -} - -func (v14gRedeemOrder) TableName() string { return "redeem_order" } - -type v14gRedeemOptionKey struct { - ID int64 `xorm:"pk autoincr"` - OptionID int64 `xorm:"INDEX NOT NULL"` - KeyValue string `xorm:"TEXT NOT NULL"` - IsUsed bool `xorm:"NOT NULL DEFAULT false"` - OrderID int64 `xorm:"INDEX"` - CreatedUnix int64 `xorm:"created"` -} - -func (v14gRedeemOptionKey) TableName() string { return "redeem_option_key" } - -func addCreditsFulfillHackathonPrizes(x *xorm.Engine) error { - // Add columns to existing tables - if err := x.Sync(new(v14gHackathonTrack)); err != nil { - return err - } - if err := x.Sync(new(v14gRedeemOption)); err != nil { - return err - } - if err := x.Sync(new(v14gRedeemOrder)); err != nil { - return err - } - // Create new table - return x.Sync(new(v14gRedeemOptionKey)) -} -``` - -- [ ] **Step 2: Verify it compiles** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/phase2-credits && go build ./models/forgejo_migrations/...` -Expected: compiles with no errors - -- [ ] **Step 3: Commit** - -```bash -git add models/forgejo_migrations/v14g_credits-fulfill-hackathon-prizes.go -git commit -m "migration(v14g): add credits fulfill + hackathon prize fields" -``` - ---- - -### Task 2: HackathonTrack Model — Add Distribution Fields - -**Files:** -- Modify: `models/hackforger/hackathon_track.go` (struct at line 16, add after `PrizeCredits` line 24) - -- [ ] **Step 1: Add fields to HackathonTrack struct** - -In `models/hackforger/hackathon_track.go`, add after the `PrizeCredits` field (line 24): - -```go - PrizeDistMode string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'winner_takes_all'"` - PrizeDistRatios string `xorm:"TEXT NOT NULL DEFAULT ''"` -``` - -- [ ] **Step 2: Add PrizeDistRatio helper type and validation** - -Add at the end of `models/hackforger/hackathon_track.go`: - -```go -// PrizeDistRatio represents one entry in the prize distribution ratios JSON. -type PrizeDistRatio struct { - Rank int `json:"rank"` - Pct int `json:"pct"` -} - -// ParsePrizeDistRatios parses the JSON string into a slice of PrizeDistRatio. -func ParsePrizeDistRatios(s string) ([]PrizeDistRatio, error) { - if s == "" { - return nil, nil - } - var ratios []PrizeDistRatio - if err := json.Unmarshal([]byte(s), &ratios); err != nil { - return nil, err - } - return ratios, nil -} - -// ValidatePrizeDistRatios checks: all pct > 0, sum == 100, ranks contiguous 1..N. -func ValidatePrizeDistRatios(ratios []PrizeDistRatio) error { - if len(ratios) == 0 { - return ErrInvalidDistRatios{Reason: "ratios must not be empty"} - } - total := 0 - for i, r := range ratios { - if r.Rank != i+1 { - return ErrInvalidDistRatios{Reason: fmt.Sprintf("ranks must be contiguous 1..N, got rank %d at position %d", r.Rank, i+1)} - } - if r.Pct <= 0 { - return ErrInvalidDistRatios{Reason: fmt.Sprintf("pct must be > 0 for rank %d", r.Rank)} - } - total += r.Pct - } - if total != 100 { - return ErrInvalidDistRatios{Reason: fmt.Sprintf("sum of pct must be 100, got %d", total)} - } - return nil -} - -// ErrInvalidDistRatios is returned when prize distribution ratios are invalid. -type ErrInvalidDistRatios struct { - Reason string -} - -func (err ErrInvalidDistRatios) Error() string { - return fmt.Sprintf("invalid prize distribution ratios: %s", err.Reason) -} - -func IsErrInvalidDistRatios(err error) bool { - _, ok := err.(ErrInvalidDistRatios) - return ok -} -``` - -Add `"encoding/json"` to the import block. - -- [ ] **Step 3: Write tests for ratio validation** - -Create or append to `models/hackforger/hackathon_track_test.go`: - -```go -package hackforger - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestValidatePrizeDistRatios_Valid(t *testing.T) { - ratios := []PrizeDistRatio{ - {Rank: 1, Pct: 50}, - {Rank: 2, Pct: 30}, - {Rank: 3, Pct: 20}, - } - assert.NoError(t, ValidatePrizeDistRatios(ratios)) -} - -func TestValidatePrizeDistRatios_SumNot100(t *testing.T) { - ratios := []PrizeDistRatio{ - {Rank: 1, Pct: 50}, - {Rank: 2, Pct: 30}, - } - err := ValidatePrizeDistRatios(ratios) - require.Error(t, err) - assert.True(t, IsErrInvalidDistRatios(err)) -} - -func TestValidatePrizeDistRatios_NonContiguous(t *testing.T) { - ratios := []PrizeDistRatio{ - {Rank: 1, Pct: 50}, - {Rank: 3, Pct: 50}, - } - err := ValidatePrizeDistRatios(ratios) - require.Error(t, err) - assert.True(t, IsErrInvalidDistRatios(err)) -} - -func TestValidatePrizeDistRatios_ZeroPct(t *testing.T) { - ratios := []PrizeDistRatio{ - {Rank: 1, Pct: 100}, - {Rank: 2, Pct: 0}, - } - err := ValidatePrizeDistRatios(ratios) - require.Error(t, err) - assert.True(t, IsErrInvalidDistRatios(err)) -} - -func TestValidatePrizeDistRatios_Empty(t *testing.T) { - err := ValidatePrizeDistRatios(nil) - require.Error(t, err) -} - -func TestParsePrizeDistRatios(t *testing.T) { - ratios, err := ParsePrizeDistRatios(`[{"rank":1,"pct":60},{"rank":2,"pct":40}]`) - require.NoError(t, err) - assert.Len(t, ratios, 2) - assert.Equal(t, 60, ratios[0].Pct) -} -``` - -- [ ] **Step 4: Run tests** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/phase2-credits && go test ./models/hackforger/ -run TestValidatePrizeDistRatios -v && go test ./models/hackforger/ -run TestParsePrizeDistRatios -v` -Expected: all PASS - -- [ ] **Step 5: Commit** - -```bash -git add models/hackforger/hackathon_track.go models/hackforger/hackathon_track_test.go -git commit -m "feat(credits): add prize distribution mode + ratios to HackathonTrack" -``` - ---- - -### Task 3: RedeemOption Model — Add FulfillMode - -**Files:** -- Modify: `models/hackforger/redeem_option.go` (struct at line 16, add after `IsActive` line 22) - -- [ ] **Step 1: Add FulfillMode field** - -In `models/hackforger/redeem_option.go`, add after the `IsActive` field (line 22): - -```go - FulfillMode string `xorm:"VARCHAR(16) NOT NULL DEFAULT 'manual'"` -``` - -- [ ] **Step 2: Verify it compiles** - -Run: `go build ./models/hackforger/...` -Expected: compiles with no errors - -- [ ] **Step 3: Commit** - -```bash -git add models/hackforger/redeem_option.go -git commit -m "feat(credits): add FulfillMode field to RedeemOption" -``` - ---- - -### Task 4: RedeemOrder Model — Add Delivery Fields - -**Files:** -- Modify: `models/hackforger/redeem_order.go` (struct at line 25, add after `FulfillNote` line 31) - -- [ ] **Step 1: Add delivery fields** - -In `models/hackforger/redeem_order.go`, add after the `FulfillNote` field (line 31): - -```go - DeliveryType string `xorm:"VARCHAR(32) NOT NULL DEFAULT ''"` - DeliveryValue string `xorm:"TEXT NOT NULL DEFAULT ''"` -``` - -- [ ] **Step 2: Verify it compiles** - -Run: `go build ./models/hackforger/...` -Expected: compiles with no errors - -- [ ] **Step 3: Commit** - -```bash -git add models/hackforger/redeem_order.go -git commit -m "feat(credits): add delivery fields to RedeemOrder" -``` - ---- - -### Task 5: New Model — RedeemOptionKey - -**Files:** -- Create: `models/hackforger/redeem_option_key.go` -- Create: `models/hackforger/redeem_option_key_test.go` - -- [ ] **Step 1: Write the model file** - -```go -// Copyright 2026 The HackForger Authors. All rights reserved. -// SPDX-License-Identifier: GPL-3.0-or-later - -package hackforger - -import ( - "context" - "fmt" - - "forgejo.org/models/db" - "forgejo.org/modules/timeutil" - "forgejo.org/modules/util" -) - -// RedeemOptionKey represents a single key/code in an auto-fulfill option's key pool. -type RedeemOptionKey struct { - ID int64 `xorm:"pk autoincr"` - OptionID int64 `xorm:"INDEX NOT NULL"` - KeyValue string `xorm:"TEXT NOT NULL"` - IsUsed bool `xorm:"NOT NULL DEFAULT false"` - OrderID int64 `xorm:"INDEX"` - CreatedUnix timeutil.TimeStamp `xorm:"created"` -} - -func init() { - db.RegisterModel(new(RedeemOptionKey)) -} - -// AddKeys bulk-inserts keys for an option. -func AddKeys(ctx context.Context, optionID int64, keys []string) error { - beans := make([]any, 0, len(keys)) - for _, k := range keys { - beans = append(beans, &RedeemOptionKey{ - OptionID: optionID, - KeyValue: k, - }) - } - return db.Insert(ctx, beans...) -} - -// ClaimKey atomically claims an unused key for an order. -// Uses SELECT then UPDATE for SQLite compatibility. -// Caller must wrap in db.WithTx for transactional safety. -func ClaimKey(ctx context.Context, optionID, orderID int64) (*RedeemOptionKey, error) { - key := new(RedeemOptionKey) - has, err := db.GetEngine(ctx). - Where("option_id = ? AND is_used = ?", optionID, false). - OrderBy("id ASC"). - ForUpdate(). - Limit(1). - Get(key) - if err != nil { - return nil, err - } - if !has { - return nil, ErrOutOfStock{OptionID: optionID} - } - - key.IsUsed = true - key.OrderID = orderID - if _, err := db.GetEngine(ctx).ID(key.ID).Cols("is_used", "order_id").Update(key); err != nil { - return nil, err - } - return key, nil -} - -// CountAvailableKeys returns the number of unused keys for an option. -func CountAvailableKeys(ctx context.Context, optionID int64) (int64, error) { - return db.GetEngine(ctx).Where("option_id = ? AND is_used = ?", optionID, false).Count(new(RedeemOptionKey)) -} - -// CountTotalKeys returns total key count for an option. -func CountTotalKeys(ctx context.Context, optionID int64) (int64, error) { - return db.GetEngine(ctx).Where("option_id = ?", optionID).Count(new(RedeemOptionKey)) -} - -// ListKeysByOption returns all keys for an option (admin use). -func ListKeysByOption(ctx context.Context, optionID int64) ([]*RedeemOptionKey, error) { - var keys []*RedeemOptionKey - err := db.GetEngine(ctx).Where("option_id = ?", optionID).OrderBy("id ASC").Find(&keys) - return keys, err -} - -// ErrKeyPoolEmpty is returned when no unused keys are available. -type ErrKeyPoolEmpty struct { - OptionID int64 -} - -func (err ErrKeyPoolEmpty) Error() string { - return fmt.Sprintf("key pool is empty for option [id: %d]", err.OptionID) -} - -func (err ErrKeyPoolEmpty) Unwrap() error { - return util.ErrNotExist -} - -func IsErrKeyPoolEmpty(err error) bool { - _, ok := err.(ErrKeyPoolEmpty) - return ok -} -``` - -- [ ] **Step 2: Write tests** - -Create `models/hackforger/redeem_option_key_test.go`: - -```go -package hackforger - -import ( - "testing" - - "forgejo.org/models/db" - "forgejo.org/models/unittest" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAddKeys(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - keys := []string{"KEY-001", "KEY-002", "KEY-003"} - require.NoError(t, AddKeys(db.DefaultContext, 1, keys)) - - total, err := CountTotalKeys(db.DefaultContext, 1) - require.NoError(t, err) - assert.Equal(t, int64(3), total) -} - -func TestClaimKey(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - require.NoError(t, AddKeys(db.DefaultContext, 1, []string{"CLAIM-A", "CLAIM-B"})) - - key, err := ClaimKey(db.DefaultContext, 1, 99) - require.NoError(t, err) - assert.Equal(t, "CLAIM-A", key.KeyValue) - assert.True(t, key.IsUsed) - assert.Equal(t, int64(99), key.OrderID) - - avail, err := CountAvailableKeys(db.DefaultContext, 1) - require.NoError(t, err) - assert.Equal(t, int64(1), avail) -} - -func TestClaimKey_Empty(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - _, err := ClaimKey(db.DefaultContext, 999, 1) - require.Error(t, err) - assert.True(t, IsErrOutOfStock(err)) -} -``` - -- [ ] **Step 3: Add empty fixture file** - -Create `models/fixtures/redeem_option_key.yml`: - -```yaml -[] -``` - -- [ ] **Step 4: Run tests** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/phase2-credits && go test ./models/hackforger/ -run TestAddKeys -v && go test ./models/hackforger/ -run TestClaimKey -v` -Expected: all PASS - -- [ ] **Step 5: Commit** - -```bash -git add models/hackforger/redeem_option_key.go models/hackforger/redeem_option_key_test.go models/fixtures/redeem_option_key.yml -git commit -m "feat(credits): add RedeemOptionKey model for auto-fulfill key pool" -``` - ---- - -### Task 6: Action Types — Order Fulfilled/Cancelled - -**Files:** -- Modify: `models/hackforger/action_types.go` (add after line 37 `ActionGrantRoundCancelled`) - -- [ ] **Step 1: Add new action type constants** - -In `models/hackforger/action_types.go`, add after `ActionGrantRoundCancelled` (line 37): - -```go - ActionOrderFulfilled activities_model.ActionType = 58 - ActionOrderCancelled activities_model.ActionType = 59 -``` - -- [ ] **Step 2: Add string mappings** - -In the `HackforgerActionTypeName` map (after line 66): - -```go - ActionOrderFulfilled: "order_fulfilled", - ActionOrderCancelled: "order_cancelled", -``` - -- [ ] **Step 3: Verify compiles** - -Run: `go build ./models/hackforger/...` -Expected: compiles with no errors - -- [ ] **Step 4: Commit** - -```bash -git add models/hackforger/action_types.go -git commit -m "feat(credits): add order_fulfilled/cancelled action types (58/59)" -``` - ---- - -### Task 7: Test Fixtures — Hackathon Prize Distribution Data - -**Files:** -- Modify: `models/fixtures/hackathon.yml` -- Modify: `models/fixtures/hackathon_track.yml` -- Modify: `models/fixtures/hackathon_submission.yml` -- Modify: `models/fixtures/hackathon_judge_score.yml` - -These fixtures support the `distributeHackathonCredits` tests in Chunk 2. - -- [ ] **Step 1: Add hackathon fixture** - -Replace `models/fixtures/hackathon.yml` content: - -```yaml -- - id: 1 - name: "Test Hackathon" - slug: "test-hackathon" - status: 4 - creator_id: 1 - created_unix: 1609459200 - updated_unix: 1609459200 -``` - -(Status 4 = Judging, so FinalizeHackathon can transition it to Finished.) - -- [ ] **Step 2: Add track fixtures with distribution modes** - -Replace `models/fixtures/hackathon_track.yml` content: - -```yaml -- - id: 1 - hackathon_id: 1 - name: "Winner Takes All Track" - prize_credits: 1000 - prize_dist_mode: "winner_takes_all" - prize_dist_ratios: "" - created_unix: 1609459200 -- - id: 2 - hackathon_id: 1 - name: "Tiered Track" - prize_credits: 1000 - prize_dist_mode: "tiered" - prize_dist_ratios: '[{"rank":1,"pct":50},{"rank":2,"pct":30},{"rank":3,"pct":20}]' - created_unix: 1609459200 -- - id: 3 - hackathon_id: 1 - name: "Equal Track" - prize_credits: 1000 - prize_dist_mode: "equal" - prize_dist_ratios: "" - created_unix: 1609459200 -- - id: 4 - hackathon_id: 1 - name: "Zero Prize Track" - prize_credits: 0 - prize_dist_mode: "winner_takes_all" - prize_dist_ratios: "" - created_unix: 1609459200 -``` - -- [ ] **Step 3: Add submission fixtures with scores** - -Replace `models/fixtures/hackathon_submission.yml` content: - -```yaml -# Track 1 (winner_takes_all): user 2 is rank 1 -- - id: 1 - hackathon_id: 1 - registration_id: 1 - user_id: 2 - track_id: 1 - title: "Submission A" - status: 2 - total_score: 9.5 - rank: 1 - created_unix: 1609459200 -- - id: 2 - hackathon_id: 1 - registration_id: 2 - user_id: 3 - track_id: 1 - title: "Submission B" - status: 2 - total_score: 7.0 - rank: 2 - created_unix: 1609459200 -# Track 2 (tiered): 3 users with scores -- - id: 3 - hackathon_id: 1 - registration_id: 3 - user_id: 4 - track_id: 2 - title: "Submission C" - status: 2 - total_score: 9.0 - rank: 1 - created_unix: 1609459200 -- - id: 4 - hackathon_id: 1 - registration_id: 4 - user_id: 5 - track_id: 2 - title: "Submission D" - status: 2 - total_score: 8.0 - rank: 2 - created_unix: 1609459200 -- - id: 5 - hackathon_id: 1 - registration_id: 5 - user_id: 6 - track_id: 2 - title: "Submission E" - status: 2 - total_score: 7.0 - rank: 3 - created_unix: 1609459200 -# Track 3 (equal): 2 users with scores -- - id: 6 - hackathon_id: 1 - registration_id: 6 - user_id: 7 - track_id: 3 - title: "Submission F" - status: 2 - total_score: 8.5 - rank: 1 - created_unix: 1609459200 -- - id: 7 - hackathon_id: 1 - registration_id: 7 - user_id: 8 - track_id: 3 - title: "Submission G" - status: 2 - total_score: 6.0 - rank: 2 - created_unix: 1609459200 -``` - -- [ ] **Step 4: Add redeem fixtures for fulfill tests** - -Append to `models/fixtures/redeem_option.yml` (file likely exists with fixture ID 1): - -```yaml -- - id: 2 - name: "Auto License Key" - description: "Software license" - cost: 100 - stock: 2 - is_active: true - fulfill_mode: "auto" - created_unix: 1609459200 -``` - -Append to `models/fixtures/redeem_order.yml` (add pending orders for batch test): - -```yaml -- - id: 2 - user_id: 3 - option_id: 1 - cost: 500 - status: "pending" - created_unix: 1609459300 -- - id: 3 - user_id: 5 - option_id: 1 - cost: 500 - status: "pending" - created_unix: 1609459400 -``` - -- [ ] **Step 5: Commit** - -```bash -git add models/fixtures/ -git commit -m "test: add fixtures for hackathon prize distribution + fulfill tests" -``` - ---- - -## Chunk 2: Hackathon -> Credits Service - -### Task 8: distributeHackathonCredits Service Function - -**Files:** -- Modify: `services/hackforger/hackathon.go` (add function, wire into FinalizeHackathon at line 193) -- Create or modify: `services/hackforger/hackathon_test.go` - -- [ ] **Step 1: Write failing tests for all 3 distribution modes** - -Create `services/hackforger/hackathon_credits_test.go`: - -```go -package hackforger - -import ( - "testing" - - "forgejo.org/models/db" - hackforger_model "forgejo.org/models/hackforger" - "forgejo.org/models/unittest" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestDistributeHackathonCredits_WinnerTakesAll(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Track 1: winner_takes_all, PrizeCredits=1000, user 2 has highest score - err := distributeHackathonCredits(db.DefaultContext, 1) - require.NoError(t, err) - - // User 2 (rank 1 in track 1) should get 1000 - acct, err := GetOrCreateCreditAccount(db.DefaultContext, 2) - require.NoError(t, err) - assert.Equal(t, int64(1000), acct.Balance) - - // User 3 (rank 2 in track 1) should get 0 from this track - acct3, err := GetOrCreateCreditAccount(db.DefaultContext, 3) - require.NoError(t, err) - // Balance may include credits from other tracks — check transaction reference - txns, _, err := ListTransactions(db.DefaultContext, 3, db.ListOptions{Page: 1, PageSize: 50}) - require.NoError(t, err) - hasTrack1 := false - for _, tx := range txns { - if tx.Reference == "hackathon:1/track:1:rank:2" { - hasTrack1 = true - } - } - assert.False(t, hasTrack1, "user 3 should NOT receive credits from winner_takes_all track") -} - -func TestDistributeHackathonCredits_Tiered(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - err := distributeHackathonCredits(db.DefaultContext, 1) - require.NoError(t, err) - - // Track 2: tiered [50/30/20] with 1000 credits - // User 4: rank 1 -> 500, User 5: rank 2 -> 300, User 6: rank 3 -> 200 - acct4, _ := GetOrCreateCreditAccount(db.DefaultContext, 4) - acct5, _ := GetOrCreateCreditAccount(db.DefaultContext, 5) - acct6, _ := GetOrCreateCreditAccount(db.DefaultContext, 6) - - // User 4 may have initial balance from fixture (1000), so check delta via transactions - txns4, _, _ := ListTransactions(db.DefaultContext, 4, db.ListOptions{Page: 1, PageSize: 50}) - var track2Amount int64 - for _, tx := range txns4 { - if tx.Reference == "hackathon:1/track:2:rank:1" { - track2Amount += tx.Amount - } - } - assert.Equal(t, int64(500), track2Amount, "user 4 should get 500 from tiered track") - - _ = acct4 - _ = acct5 - _ = acct6 - - txns5, _, _ := ListTransactions(db.DefaultContext, 5, db.ListOptions{Page: 1, PageSize: 50}) - var t2u5 int64 - for _, tx := range txns5 { - if tx.Reference == "hackathon:1/track:2:rank:2" { - t2u5 += tx.Amount - } - } - assert.Equal(t, int64(300), t2u5) - - txns6, _, _ := ListTransactions(db.DefaultContext, 6, db.ListOptions{Page: 1, PageSize: 50}) - var t2u6 int64 - for _, tx := range txns6 { - if tx.Reference == "hackathon:1/track:2:rank:3" { - t2u6 += tx.Amount - } - } - assert.Equal(t, int64(200), t2u6) -} - -func TestDistributeHackathonCredits_Equal(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - err := distributeHackathonCredits(db.DefaultContext, 1) - require.NoError(t, err) - - // Track 3: equal, 1000 credits, 2 users -> 500 each - txns7, _, _ := ListTransactions(db.DefaultContext, 7, db.ListOptions{Page: 1, PageSize: 50}) - var t3u7 int64 - for _, tx := range txns7 { - if tx.Reference == "hackathon:1/track:3:rank:1" { - t3u7 += tx.Amount - } - } - assert.Equal(t, int64(500), t3u7) - - txns8, _, _ := ListTransactions(db.DefaultContext, 8, db.ListOptions{Page: 1, PageSize: 50}) - var t3u8 int64 - for _, tx := range txns8 { - if tx.Reference == "hackathon:1/track:3:rank:2" { - t3u8 += tx.Amount - } - } - assert.Equal(t, int64(500), t3u8) -} - -func TestDistributeHackathonCredits_EqualRemainder(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Temporarily set track 3 prize to 999 to test remainder - track := unittest.AssertExistsAndLoadBean(t, &hackforger_model.HackathonTrack{ID: 3}) - track.PrizeCredits = 999 - require.NoError(t, hackforger_model.UpdateTrack(db.DefaultContext, track)) - - err := distributeHackathonCredits(db.DefaultContext, 1) - require.NoError(t, err) - - // 999 / 2 = 499 per user, remainder 1 goes to rank 1 (user 7) - txns7, _, _ := ListTransactions(db.DefaultContext, 7, db.ListOptions{Page: 1, PageSize: 50}) - var t3u7 int64 - for _, tx := range txns7 { - if tx.Reference == "hackathon:1/track:3:rank:1" { - t3u7 += tx.Amount - } - } - assert.Equal(t, int64(500), t3u7) // 499 + 1 remainder - - txns8, _, _ := ListTransactions(db.DefaultContext, 8, db.ListOptions{Page: 1, PageSize: 50}) - var t3u8 int64 - for _, tx := range txns8 { - if tx.Reference == "hackathon:1/track:3:rank:2" { - t3u8 += tx.Amount - } - } - assert.Equal(t, int64(499), t3u8) -} - -func TestDistributeHackathonCredits_ZeroPrize(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Track 4 has PrizeCredits=0, should be skipped - err := distributeHackathonCredits(db.DefaultContext, 1) - require.NoError(t, err) - // No error, no deposits for track 4 — test passes if no panic -} -``` - -- [ ] **Step 2: Run tests — verify they fail** - -Run: `go test ./services/hackforger/ -run TestDistributeHackathonCredits -v` -Expected: FAIL — `distributeHackathonCredits` undefined - -- [ ] **Step 3: Implement distributeHackathonCredits** - -Add to `services/hackforger/hackathon.go`: - -```go -// distributeHackathonCredits awards prize credits for each track -// based on the track's distribution mode and per-track score ranking. -func distributeHackathonCredits(ctx context.Context, hackathonID int64) error { - tracks, err := hackforger_model.ListTracksByHackathon(ctx, hackathonID) - if err != nil { - return err - } - - for _, track := range tracks { - if track.PrizeCredits <= 0 { - continue - } - - subs, _, err := hackforger_model.ListSubmissions(ctx, hackforger_model.ListSubmissionsOptions{ - TrackID: track.ID, - ListOptions: db.ListOptions{ListAll: true}, - }) - if err != nil { - return err - } - - // Per-track ranking: sort by TotalScore descending (independent of global Rank) - sort.Slice(subs, func(i, j int) bool { - if subs[i].TotalScore != subs[j].TotalScore { - return subs[i].TotalScore > subs[j].TotalScore - } - return subs[i].ID < subs[j].ID - }) - - // Filter out unscored submissions - var ranked []*hackforger_model.HackathonSubmission - for _, s := range subs { - if s.TotalScore > 0 { - ranked = append(ranked, s) - } - } - if len(ranked) == 0 { - continue - } - - switch track.PrizeDistMode { - case "winner_takes_all": - if err := Deposit(ctx, ranked[0].UserID, track.PrizeCredits, - fmt.Sprintf("hackathon:%d/track:%d:rank:1", hackathonID, track.ID), - fmt.Sprintf("Hackathon winner: %s", track.Name), - ); err != nil { - return err - } - - case "tiered": - ratios, err := hackforger_model.ParsePrizeDistRatios(track.PrizeDistRatios) - if err != nil { - return err - } - var deposited int64 - for _, r := range ratios { - idx := r.Rank - 1 - if idx >= len(ranked) { - continue - } - amount := track.PrizeCredits * int64(r.Pct) / 100 - deposited += amount - if err := Deposit(ctx, ranked[idx].UserID, amount, - fmt.Sprintf("hackathon:%d/track:%d:rank:%d", hackathonID, track.ID, r.Rank), - fmt.Sprintf("Hackathon rank %d: %s", r.Rank, track.Name), - ); err != nil { - return err - } - } - // Remainder to rank 1 - if remainder := track.PrizeCredits - deposited; remainder > 0 { - if err := Deposit(ctx, ranked[0].UserID, remainder, - fmt.Sprintf("hackathon:%d/track:%d:rank:1:remainder", hackathonID, track.ID), - fmt.Sprintf("Hackathon rounding remainder: %s", track.Name), - ); err != nil { - return err - } - } - - case "equal": - n := int64(len(ranked)) - perUser := track.PrizeCredits / n - remainder := track.PrizeCredits % n - for i, sub := range ranked { - amount := perUser - if i == 0 { - amount += remainder - } - if err := Deposit(ctx, sub.UserID, amount, - fmt.Sprintf("hackathon:%d/track:%d:rank:%d", hackathonID, track.ID, i+1), - fmt.Sprintf("Hackathon participant: %s", track.Name), - ); err != nil { - return err - } - } - } - } - return nil -} -``` - -Add `"sort"` to imports. - -- [ ] **Step 4: Wire into FinalizeHackathon** - -In `services/hackforger/hackathon.go`, in `FinalizeHackathon()`, add after line 193 (`CalculateRanks` succeeds) and before `UpdateHackathonStatus`: - -```go - if err := distributeHackathonCredits(ctx, h.ID); err != nil { - return err - } -``` - -- [ ] **Step 5: Run tests — verify they pass** - -Run: `go test ./services/hackforger/ -run TestDistributeHackathonCredits -v` -Expected: all PASS - -- [ ] **Step 6: Commit** - -```bash -git add services/hackforger/hackathon.go services/hackforger/hackathon_credits_test.go -git commit -m "feat(credits): hackathon prize distribution on finalize (3 modes)" -``` - ---- - -## Chunk 3: Fulfill Flow — Service Layer - -### Task 9: AudienceDirectUser in Notifier (Separate Commit) - -**Files:** -- Modify: `services/hackforger/notifier.go` (lines 35-40 for enum, ~line 97 for switch) - -- [ ] **Step 1: Add AudienceDirectUser enum value** - -In `services/hackforger/notifier.go`, add after `AudienceRepoWatchers` (line 39): - -```go - AudienceDirectUser // Visible to a specific user (opts.TargetUserID) -``` - -- [ ] **Step 2: Add TargetUserID to opts** - -In `HackforgerActionOpts` struct (line 43), add: - -```go - TargetUserID int64 // used when AudienceType == AudienceDirectUser -``` - -- [ ] **Step 3: Add switch case in PublishHackforgerAction** - -In the switch block (around line 97), add a new case before the closing `}`: - -```go - case AudienceDirectUser: - if opts.TargetUserID > 0 && opts.TargetUserID != opts.ActUserID { - directAction := &activities_model.Action{ - ActUserID: opts.ActUserID, - UserID: opts.TargetUserID, - OpType: opts.OpType, - Content: contentStr, - RepoID: opts.RepoID, - CreatedUnix: now, - } - if _, err := db.GetEngine(ctx).Insert(directAction); err != nil { - return err - } - } -``` - -- [ ] **Step 4: Verify compiles** - -Run: `go build ./services/hackforger/...` -Expected: compiles with no errors - -- [ ] **Step 5: Commit (separate, isolated)** - -```bash -git add services/hackforger/notifier.go -git commit -m "feat(feed): add AudienceDirectUser for targeted notifications - -Isolated commit — Line-B feed rework can rebase independently." -``` - ---- - -### Task 10: Update FulfillOrder + Add notifyOrderStatusChange - -**Files:** -- Modify: `services/hackforger/credits.go` (FulfillOrder at line 265, CancelOrder at line 285) - -- [ ] **Step 1: Write test for updated FulfillOrder with delivery fields** - -Add to `services/hackforger/credits_test.go`: - -```go -func TestFulfillOrder_WithDelivery(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) - - err := FulfillOrder(db.DefaultContext, admin, 1, "Enjoy!", "license_key", "ABCD-1234-EFGH") - require.NoError(t, err) - - order, err := hackforger_model.GetRedeemOrderByID(db.DefaultContext, 1) - require.NoError(t, err) - assert.Equal(t, hackforger_model.OrderStatusFulfilled, order.Status) - assert.Equal(t, "license_key", order.DeliveryType) - assert.Equal(t, "ABCD-1234-EFGH", order.DeliveryValue) - assert.Equal(t, "Enjoy!", order.FulfillNote) -} -``` - -- [ ] **Step 2: Run test — verify it fails** - -Run: `go test ./services/hackforger/ -run TestFulfillOrder_WithDelivery -v` -Expected: FAIL — too many arguments to FulfillOrder - -- [ ] **Step 3: Update FulfillOrder signature and implementation** - -In `services/hackforger/credits.go`, replace the `FulfillOrder` function (line 265): - -```go -// FulfillOrder marks a pending order as fulfilled with optional delivery info. -func FulfillOrder(ctx context.Context, admin *user_model.User, orderID int64, note, deliveryType, deliveryValue string) error { - if !admin.IsAdmin { - return ErrNotAdmin{UserID: admin.ID} - } - - order, err := hackforger_model.GetRedeemOrderByID(ctx, orderID) - if err != nil { - return err - } - - if order.Status != hackforger_model.OrderStatusPending { - return ErrOrderNotPending{OrderID: orderID, Status: order.Status} - } - - order.Status = hackforger_model.OrderStatusFulfilled - order.FulfillNote = note - order.DeliveryType = deliveryType - order.DeliveryValue = deliveryValue - if err := hackforger_model.UpdateRedeemOrder(ctx, order); err != nil { - return err - } - - // Load option name for notification - opt, _ := hackforger_model.GetRedeemOptionByID(ctx, order.OptionID) - optName := "" - if opt != nil { - optName = opt.Name - } - notifyOrderStatusChange(ctx, order, admin.ID, hackforger_model.ActionOrderFulfilled, optName) - return nil -} -``` - -- [ ] **Step 4: Add notifyOrderStatusChange helper** - -Add to `services/hackforger/credits.go`: - -```go -// notifyOrderStatusChange publishes a feed event for order status changes. -// Isolated from feed core — targets the order owner directly. -func notifyOrderStatusChange(ctx context.Context, order *hackforger_model.RedeemOrder, adminID int64, actionType activities_model.ActionType, optionName string) { - _ = PublishHackforgerAction(ctx, &HackforgerActionOpts{ - ActUserID: adminID, - OpType: actionType, - AudienceType: AudienceDirectUser, - TargetUserID: order.UserID, - Content: &hackforger_model.HackforgerActionContent{ - EntityType: "credits", - EntityName: optionName, - Extra: map[string]any{"order_id": order.ID}, - }, - }) -} -``` - -Add `activities_model "forgejo.org/models/activities"` to imports if not present. - -- [ ] **Step 5: Update CancelOrder to also notify** - -In `CancelOrder`, add before the final `return err` (inside the tx, after the refund transaction insert): - -```go - // Notify after tx completes — load option name - var optName string - if opt, _ := hackforger_model.GetRedeemOptionByID(ctx, order.OptionID); opt != nil { - optName = opt.Name - } -``` - -And after the `db.WithTx` block, before the final `return`: - -```go - notifyOrderStatusChange(ctx, order, admin.ID, hackforger_model.ActionOrderCancelled, optName) -``` - -Note: `order` and `optName` need to be captured outside the transaction closure. Refactor CancelOrder to capture the order/optName from inside the tx. - -- [ ] **Step 6: Fix all callers of FulfillOrder (web + API handlers)** - -In `routers/web/hackforger/credits.go`, update the `AdminCreditOrdersFulfill` handler call from: -```go -FulfillOrder(ctx, admin, orderID, note) -``` -to: -```go -FulfillOrder(ctx, admin, orderID, note, "", "") -``` - -In `routers/api/v1/hackforger/credits.go`, update similarly. Also add `DeliveryType` and `DeliveryValue` to the `FulfillOrderForm`: - -```go -type FulfillOrderForm struct { - Note string `json:"note"` - DeliveryType string `json:"delivery_type"` - DeliveryValue string `json:"delivery_value"` -} -``` - -And pass them through: -```go -FulfillOrder(ctx, admin, orderID, form.Note, form.DeliveryType, form.DeliveryValue) -``` - -- [ ] **Step 7: Run tests — verify they pass** - -Run: `go test ./services/hackforger/ -run TestFulfillOrder -v` -Expected: all PASS (including old TestFulfillOrder test which may need signature update) - -- [ ] **Step 8: Commit** - -```bash -git add services/hackforger/credits.go services/hackforger/credits_test.go routers/web/hackforger/credits.go routers/api/v1/hackforger/credits.go -git commit -m "feat(credits): delivery details + order status notifications (F1+F2)" -``` - ---- - -### Task 11: Auto-Fulfill in Redeem() - -**Files:** -- Modify: `services/hackforger/credits.go` (Redeem function at line 60) - -- [ ] **Step 1: Write test for auto-fulfill** - -Add to `services/hackforger/credits_test.go`: - -```go -func TestRedeem_AutoFulfill(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Setup: user 4 has balance 1000, option 2 is auto-fulfill with cost 100 - // Add keys to the pool - require.NoError(t, hackforger_model.AddKeys(db.DefaultContext, 2, []string{"AUTO-KEY-1", "AUTO-KEY-2"})) - - order, err := Redeem(db.DefaultContext, 4, 2) - require.NoError(t, err) - assert.Equal(t, hackforger_model.OrderStatusFulfilled, order.Status) - assert.Equal(t, "license_key", order.DeliveryType) - assert.Equal(t, "AUTO-KEY-1", order.DeliveryValue) - - // Balance should be 1000 - 100 = 900 - acct, _ := GetOrCreateCreditAccount(db.DefaultContext, 4) - assert.Equal(t, int64(900), acct.Balance) - - // Available keys should be 1 - avail, _ := hackforger_model.CountAvailableKeys(db.DefaultContext, 2) - assert.Equal(t, int64(1), avail) -} - -func TestRedeem_AutoFulfill_NoKeys(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Option 2 is auto-fulfill but has no keys - _, err := Redeem(db.DefaultContext, 4, 2) - require.Error(t, err) - assert.True(t, hackforger_model.IsErrOutOfStock(err)) - - // Balance should be unchanged (tx rolled back) - acct, _ := GetOrCreateCreditAccount(db.DefaultContext, 4) - assert.Equal(t, int64(1000), acct.Balance) -} -``` - -- [ ] **Step 2: Run tests — verify they fail** - -Run: `go test ./services/hackforger/ -run TestRedeem_AutoFulfill -v` -Expected: FAIL - -- [ ] **Step 3: Modify Redeem() to support auto-fulfill** - -In `services/hackforger/credits.go`, inside the `Redeem` function's `db.WithTx` block, after the order is inserted (around line 130), add: - -```go - // Auto-fulfill: claim a key from the pool - if option.FulfillMode == "auto" { - key, err := hackforger_model.ClaimKey(ctx, optionID, order.ID) - if err != nil { - return err // rolls back entire tx - } - order.Status = hackforger_model.OrderStatusFulfilled - order.DeliveryType = "license_key" - order.DeliveryValue = key.KeyValue - if _, err := db.GetEngine(ctx).ID(order.ID). - Cols("status", "delivery_type", "delivery_value"). - Update(order); err != nil { - return err - } - } -``` - -- [ ] **Step 4: Run tests — verify they pass** - -Run: `go test ./services/hackforger/ -run TestRedeem_AutoFulfill -v` -Expected: all PASS - -- [ ] **Step 5: Commit** - -```bash -git add services/hackforger/credits.go services/hackforger/credits_test.go -git commit -m "feat(credits): auto-fulfill with key pool on redemption (F4)" -``` - ---- - -### Task 12: BatchFulfillOrders - -**Files:** -- Modify: `services/hackforger/credits.go` - -- [ ] **Step 1: Write test** - -Add to `services/hackforger/credits_test.go`: - -```go -func TestBatchFulfillOrders(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) - - // Orders 2 and 3 are pending (from fixtures) - success, failed, err := BatchFulfillOrders(db.DefaultContext, admin, - []int64{2, 3}, "Batch done", "custom", "Shipped via batch") - require.NoError(t, err) - assert.Equal(t, 2, success) - assert.Empty(t, failed) - - order2, _ := hackforger_model.GetRedeemOrderByID(db.DefaultContext, 2) - assert.Equal(t, hackforger_model.OrderStatusFulfilled, order2.Status) - assert.Equal(t, "custom", order2.DeliveryType) - - order3, _ := hackforger_model.GetRedeemOrderByID(db.DefaultContext, 3) - assert.Equal(t, hackforger_model.OrderStatusFulfilled, order3.Status) -} - -func TestBatchFulfillOrders_PartialFailure(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) - - // Order 1 already fulfilled in previous test setup, 2 is pending - // Fulfill order 2 first to make it non-pending - require.NoError(t, FulfillOrder(db.DefaultContext, admin, 2, "done", "", "")) - - success, failed, err := BatchFulfillOrders(db.DefaultContext, admin, - []int64{2, 3}, "Batch", "", "") - require.NoError(t, err) - assert.Equal(t, 1, success) // only order 3 - assert.Len(t, failed, 1) // order 2 fails (not pending) -} -``` - -- [ ] **Step 2: Run test — verify it fails** - -Run: `go test ./services/hackforger/ -run TestBatchFulfillOrders -v` -Expected: FAIL — BatchFulfillOrders undefined - -- [ ] **Step 3: Implement BatchFulfillOrders** - -Add to `services/hackforger/credits.go`: - -```go -// BatchFulfillOrders fulfills multiple pending orders. Each order is -// processed independently so partial success is possible. -func BatchFulfillOrders(ctx context.Context, admin *user_model.User, orderIDs []int64, note, deliveryType, deliveryValue string) (int, []int64, error) { - if !admin.IsAdmin { - return 0, nil, ErrNotAdmin{UserID: admin.ID} - } - - var success int - var failed []int64 - for _, oid := range orderIDs { - if err := FulfillOrder(ctx, admin, oid, note, deliveryType, deliveryValue); err != nil { - failed = append(failed, oid) - } else { - success++ - } - } - return success, failed, nil -} -``` - -- [ ] **Step 4: Run tests — verify they pass** - -Run: `go test ./services/hackforger/ -run TestBatchFulfillOrders -v` -Expected: all PASS - -- [ ] **Step 5: Commit** - -```bash -git add services/hackforger/credits.go services/hackforger/credits_test.go -git commit -m "feat(credits): batch fulfill orders service function (F3)" -``` - ---- - -### Task 13: Key Pool Management Service Functions - -**Files:** -- Modify: `services/hackforger/credits.go` - -- [ ] **Step 1: Write tests** - -Add to `services/hackforger/credits_test.go`: - -```go -func TestAddKeysAndSyncStock(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) - - // Option 2 is auto-fulfill - err := AddKeysToOption(db.DefaultContext, admin, 2, []string{"K1", "K2", "K3"}) - require.NoError(t, err) - - opt, err := hackforger_model.GetRedeemOptionByID(db.DefaultContext, 2) - require.NoError(t, err) - assert.Equal(t, 3, opt.Stock) // stock synced to available key count -} - -func TestGetKeyPoolStatus(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - require.NoError(t, hackforger_model.AddKeys(db.DefaultContext, 2, []string{"S1", "S2"})) - // Claim one key - _, err := hackforger_model.ClaimKey(db.DefaultContext, 2, 99) - require.NoError(t, err) - - total, avail, err := GetKeyPoolStatus(db.DefaultContext, 2) - require.NoError(t, err) - assert.Equal(t, int64(2), total) - assert.Equal(t, int64(1), avail) -} -``` - -- [ ] **Step 2: Implement service functions** - -Add to `services/hackforger/credits.go`: - -```go -// AddKeysToOption adds keys to an auto-fulfill option and syncs stock. -func AddKeysToOption(ctx context.Context, admin *user_model.User, optionID int64, keys []string) error { - if !admin.IsAdmin { - return ErrNotAdmin{UserID: admin.ID} - } - if err := hackforger_model.AddKeys(ctx, optionID, keys); err != nil { - return err - } - return syncOptionStock(ctx, optionID) -} - -// GetKeyPoolStatus returns total and available key counts for an option. -func GetKeyPoolStatus(ctx context.Context, optionID int64) (total, available int64, err error) { - total, err = hackforger_model.CountTotalKeys(ctx, optionID) - if err != nil { - return - } - available, err = hackforger_model.CountAvailableKeys(ctx, optionID) - return -} - -// syncOptionStock updates the Stock field to match available key count. -func syncOptionStock(ctx context.Context, optionID int64) error { - avail, err := hackforger_model.CountAvailableKeys(ctx, optionID) - if err != nil { - return err - } - opt, err := hackforger_model.GetRedeemOptionByID(ctx, optionID) - if err != nil { - return err - } - opt.Stock = int(avail) - if avail == 0 { - opt.IsActive = false - } - return hackforger_model.UpdateRedeemOption(ctx, opt) -} -``` - -Also update `Redeem()`: after the auto-fulfill block, add a stock sync call after the transaction: - -After the `db.WithTx` block in `Redeem()`, before the feed event publish, add: - -```go - // Sync stock for auto-fulfill options after key claim - if option.FulfillMode == "auto" { - _ = syncOptionStock(ctx, optionID) - } -``` - -Note: `option` needs to be captured from inside the tx. Declare `var option *hackforger_model.RedeemOption` before the tx and assign inside. - -- [ ] **Step 3: Run tests** - -Run: `go test ./services/hackforger/ -run "TestAddKeysAndSyncStock|TestGetKeyPoolStatus" -v` -Expected: all PASS - -- [ ] **Step 4: Commit** - -```bash -git add services/hackforger/credits.go services/hackforger/credits_test.go -git commit -m "feat(credits): key pool management + stock sync service functions" -``` - ---- - -## Chunk 4: Routes, API + Templates - -### Task 14: Web Routes — Key Pool Management + Batch Fulfill - -**Files:** -- Modify: `routers/web/hackforger/credits.go` (add handlers) -- Modify: `routers/web/web.go` (register routes) - -- [ ] **Step 1: Add key pool web handlers** - -Add to `routers/web/hackforger/credits.go`: - -```go -// AdminRedeemOptionKeys shows the key pool management page for an auto-fulfill option. -func AdminRedeemOptionKeys(ctx *context.Context) { - optionID := ctx.PathParamInt64("id") - opt, err := hackforger_model.GetRedeemOptionByID(ctx, optionID) - if err != nil { - ctx.ServerError("GetRedeemOptionByID", err) - return - } - keys, err := hackforger_model.ListKeysByOption(ctx, optionID) - if err != nil { - ctx.ServerError("ListKeysByOption", err) - return - } - total, avail, _ := hackforger_service.GetKeyPoolStatus(ctx, optionID) - - ctx.Data["Option"] = opt - ctx.Data["Keys"] = keys - ctx.Data["TotalKeys"] = total - ctx.Data["AvailableKeys"] = avail - ctx.HTML(200, "hackforger/credits/admin/keys") -} - -// AdminRedeemOptionKeysAdd handles bulk key upload. -func AdminRedeemOptionKeysAdd(ctx *context.Context) { - optionID := ctx.PathParamInt64("id") - keysRaw := ctx.FormString("keys") - lines := strings.Split(strings.TrimSpace(keysRaw), "\n") - var keys []string - for _, l := range lines { - l = strings.TrimSpace(l) - if l != "" { - keys = append(keys, l) - } - } - if len(keys) == 0 { - ctx.Flash.Error(ctx.Tr("hackforger.credits.admin.keys_empty")) - ctx.Redirect(fmt.Sprintf("/-/admin/credits/options/%d/keys", optionID)) - return - } - if err := hackforger_service.AddKeysToOption(ctx, ctx.Doer, optionID, keys); err != nil { - ctx.ServerError("AddKeysToOption", err) - return - } - ctx.Flash.Success(ctx.Tr("hackforger.credits.admin.keys_added", len(keys))) - ctx.Redirect(fmt.Sprintf("/-/admin/credits/options/%d/keys", optionID)) -} - -// AdminCreditOrdersBatchFulfill handles batch fulfillment of pending orders. -func AdminCreditOrdersBatchFulfill(ctx *context.Context) { - orderIDsStr := ctx.FormStrings("order_ids") - note := ctx.FormString("note") - deliveryType := ctx.FormString("delivery_type") - deliveryValue := ctx.FormString("delivery_value") - - var orderIDs []int64 - for _, s := range orderIDsStr { - id, _ := strconv.ParseInt(s, 10, 64) - if id > 0 { - orderIDs = append(orderIDs, id) - } - } - if len(orderIDs) == 0 { - ctx.Flash.Error(ctx.Tr("hackforger.credits.admin.no_orders_selected")) - ctx.Redirect("/-/admin/credits/orders") - return - } - - success, failed, err := hackforger_service.BatchFulfillOrders(ctx, ctx.Doer, orderIDs, note, deliveryType, deliveryValue) - if err != nil { - ctx.ServerError("BatchFulfillOrders", err) - return - } - if len(failed) > 0 { - ctx.Flash.Warning(ctx.Tr("hackforger.credits.admin.batch_partial", success, len(failed))) - } else { - ctx.Flash.Success(ctx.Tr("hackforger.credits.admin.batch_success", success)) - } - ctx.Redirect("/-/admin/credits/orders") -} -``` - -Add `"strings"` and `"strconv"` to imports. - -- [ ] **Step 2: Register routes in web.go** - -In `routers/web/web.go`, in the admin `/credits` group (around line 922), add: - -```go - m.Get("/options/{id}/keys", hackforger_web.AdminRedeemOptionKeys) - m.Post("/options/{id}/keys", hackforger_web.AdminRedeemOptionKeysAdd) - m.Post("/orders/batch-fulfill", hackforger_web.AdminCreditOrdersBatchFulfill) -``` - -- [ ] **Step 3: Verify compiles** - -Run: `go build ./routers/...` -Expected: compiles with no errors - -- [ ] **Step 4: Commit** - -```bash -git add routers/web/hackforger/credits.go routers/web/web.go -git commit -m "feat(credits): web routes for key pool + batch fulfill (F3+F4)" -``` - ---- - -### Task 15: API Routes — Key Pool + Batch Fulfill - -**Files:** -- Modify: `routers/api/v1/hackforger/credits.go` (add handlers) -- Modify: `routers/api/v1/api.go` (register routes) - -- [ ] **Step 1: Add API forms and handlers** - -Add to `routers/api/v1/hackforger/credits.go`: - -```go -// AddKeysForm is the form for adding keys to an auto-fulfill option. -type AddKeysForm struct { - Keys []string `json:"keys" binding:"Required"` -} - -// BatchFulfillForm is the form for batch order fulfillment. -type BatchFulfillForm struct { - OrderIDs []int64 `json:"order_ids" binding:"Required"` - Note string `json:"note"` - DeliveryType string `json:"delivery_type"` - DeliveryValue string `json:"delivery_value"` -} - -// AddKeys adds keys to an auto-fulfill option's key pool. -func AddKeys(ctx *context.APIContext) { - form := web.GetForm(ctx).(*AddKeysForm) - optionID := ctx.PathParamInt64("id") - if err := hackforger_service.AddKeysToOption(ctx, ctx.Doer, optionID, form.Keys); err != nil { - ctx.Error(http.StatusInternalServerError, "AddKeysToOption", err) - return - } - total, avail, _ := hackforger_service.GetKeyPoolStatus(ctx, optionID) - ctx.JSON(http.StatusOK, map[string]any{"total": total, "available": avail}) -} - -// ListKeys returns all keys for an option (admin only). -func ListKeys(ctx *context.APIContext) { - optionID := ctx.PathParamInt64("id") - keys, err := hackforger_model.ListKeysByOption(ctx, optionID) - if err != nil { - ctx.Error(http.StatusInternalServerError, "ListKeysByOption", err) - return - } - ctx.JSON(http.StatusOK, keys) -} - -// BatchFulfill fulfills multiple pending orders. -func BatchFulfill(ctx *context.APIContext) { - form := web.GetForm(ctx).(*BatchFulfillForm) - success, failed, err := hackforger_service.BatchFulfillOrders(ctx, ctx.Doer, form.OrderIDs, form.Note, form.DeliveryType, form.DeliveryValue) - if err != nil { - ctx.Error(http.StatusInternalServerError, "BatchFulfillOrders", err) - return - } - ctx.JSON(http.StatusOK, map[string]any{ - "success": success, - "failed": failed, - }) -} -``` - -- [ ] **Step 2: Register API routes in api.go** - -In `routers/api/v1/api.go`, in the credits `/redeem` group (around line 1842), add: - -```go - m.Group("/options/{id}", func() { - m.Post("/keys", reqToken(), reqSiteAdmin(), bind(hackforger_api.AddKeysForm{}), hackforger_api.AddKeys) - m.Get("/keys", reqToken(), reqSiteAdmin(), hackforger_api.ListKeys) - }) - m.Post("/orders/batch-fulfill", reqToken(), reqSiteAdmin(), bind(hackforger_api.BatchFulfillForm{}), hackforger_api.BatchFulfill) -``` - -- [ ] **Step 3: Verify compiles** - -Run: `go build ./routers/...` -Expected: compiles with no errors - -- [ ] **Step 4: Commit** - -```bash -git add routers/api/v1/hackforger/credits.go routers/api/v1/api.go -git commit -m "feat(credits): API endpoints for key pool + batch fulfill (F3+F4)" -``` - ---- - -### Task 16: Templates — Admin Key Pool Page - -**Files:** -- Create: `templates/hackforger/credits/admin/keys.tmpl` - -- [ ] **Step 1: Create the template** - -```html -{{template "base/head" .}} -
-
-

{{ctx.Locale.Tr "hackforger.credits.admin.key_pool"}} — {{.Option.Name}}

- -
-

{{ctx.Locale.Tr "hackforger.credits.admin.keys_status" .TotalKeys .AvailableKeys}}

-
- -

{{ctx.Locale.Tr "hackforger.credits.admin.add_keys"}}

-
-
- - -
- -
- -

{{ctx.Locale.Tr "hackforger.credits.admin.existing_keys"}}

- {{if .Keys}} - - - - - - - - - - - {{range .Keys}} - - - - - - - {{end}} - -
ID{{ctx.Locale.Tr "hackforger.credits.admin.key_value"}}{{ctx.Locale.Tr "hackforger.credits.admin.key_status"}}{{ctx.Locale.Tr "hackforger.credits.admin.key_order_id"}}
{{.ID}}{{.KeyValue}} - {{if .IsUsed}} - {{ctx.Locale.Tr "hackforger.credits.admin.key_used"}} - {{else}} - {{ctx.Locale.Tr "hackforger.credits.admin.key_available"}} - {{end}} - {{if .OrderID}}{{.OrderID}}{{else}}-{{end}}
- {{else}} -
{{ctx.Locale.Tr "hackforger.credits.admin.no_keys"}}
- {{end}} - - {{ctx.Locale.Tr "hackforger.credits.admin.back"}} -
-
-{{template "base/footer" .}} -``` - -- [ ] **Step 2: Commit** - -```bash -git add templates/hackforger/credits/admin/keys.tmpl -git commit -m "feat(credits): admin key pool management template" -``` - ---- - -### Task 17: Templates — Update Existing Pages - -**Files:** -- Modify: `templates/hackforger/credits/admin/options.tmpl` (add FulfillMode + key pool link) -- Modify: `templates/hackforger/credits/admin/orders.tmpl` (batch fulfill + delivery) -- Modify: `templates/hackforger/credits/orders.tmpl` (user delivery info) - -- [ ] **Step 1: Update admin options — add FulfillMode to create form** - -In `templates/hackforger/credits/admin/options.tmpl`, in the create form (around line 25, before the submit button), add: - -```html -
- - -
-``` - -In the options table, add a "Mode" column and a "Keys" link for auto options: - -After the "Active" column header, add `{{ctx.Locale.Tr "hackforger.credits.admin.fulfill_mode"}}`. - -In each row, add the mode cell and a keys link: - -```html - - {{if eq .FulfillMode "auto"}} - Auto - {{ctx.Locale.Tr "hackforger.credits.admin.manage_keys"}} - {{else}} - Manual - {{end}} - -``` - -- [ ] **Step 2: Update admin orders — add batch fulfill UI** - -In `templates/hackforger/credits/admin/orders.tmpl`: - -Add a form wrapper around the table with batch action buttons at the top: - -Before the `` tag: -```html - - -``` - -Add a checkbox column to each pending row: -```html - -``` - -After the table, close the form and add JavaScript: -```html - - -``` - -Also update the individual fulfill form to include delivery fields: -```html -
- -
-
- -
-``` - -- [ ] **Step 3: Update user orders — show delivery info** - -In `templates/hackforger/credits/orders.tmpl`, add a "Delivery" column after "Status": - -Column header: -```html - -``` - -Cell (in each row): -```html - -``` - -- [ ] **Step 4: Commit** - -```bash -git add templates/hackforger/credits/admin/options.tmpl templates/hackforger/credits/admin/orders.tmpl templates/hackforger/credits/orders.tmpl -git commit -m "feat(credits): update templates for delivery, batch fulfill, key pool UI" -``` - ---- - -### Task 18: Hackathon Manage Template — Prize Distribution UI - -**Files:** -- Modify: `templates/hackforger/hackathon/manage.tmpl` - -- [ ] **Step 1: Add prize distribution fields to track form section** - -Find the track creation/editing section in `manage.tmpl`. After the `prize_credits` input field, add: - -```html -
- - -
- -
- - -

{{ctx.Locale.Tr "hackforger.hackathon.dist_ratios_help"}}

-
- - -``` - -Note: The exact location depends on the existing template structure. Find the track form section by searching for `prize_credits` in the template. - -- [ ] **Step 2: Update track create/update handler to parse new fields** - -In the web handler for track creation/update (likely in `routers/web/hackforger/hackathon.go`), read the new form fields: - -```go -track.PrizeDistMode = ctx.FormString("prize_dist_mode") -if track.PrizeDistMode == "" { - track.PrizeDistMode = "winner_takes_all" -} -if track.PrizeDistMode == "tiered" { - ratiosJSON := ctx.FormString("prize_dist_ratios") - ratios, err := hackforger_model.ParsePrizeDistRatios(ratiosJSON) - if err != nil { - ctx.Flash.Error(ctx.Tr("hackforger.hackathon.invalid_ratios")) - ctx.Redirect(...) - return - } - if err := hackforger_model.ValidatePrizeDistRatios(ratios); err != nil { - ctx.Flash.Error(err.Error()) - ctx.Redirect(...) - return - } - track.PrizeDistRatios = ratiosJSON -} else { - track.PrizeDistRatios = "" -} -``` - -- [ ] **Step 3: Commit** - -```bash -git add templates/hackforger/hackathon/manage.tmpl routers/web/hackforger/hackathon.go -git commit -m "feat(credits): hackathon track prize distribution mode UI" -``` - ---- - -## Chunk 5: i18n + E2E - -### Task 19: Complete i18n — en-US + zh-CN - -**Files:** -- Modify: `options/locale/locale_en-US.ini` -- Modify: `options/locale/locale_zh-CN.ini` - -- [ ] **Step 1: Add new English keys** - -Append to the `[hackforger]` section of `locale_en-US.ini`: - -```ini -; Prize distribution -hackforger.hackathon.prize_dist_mode = Prize Distribution Mode -hackforger.hackathon.dist_winner_takes_all = Winner Takes All -hackforger.hackathon.dist_tiered = Tiered (Custom Ratios) -hackforger.hackathon.dist_equal = Equal Split -hackforger.hackathon.dist_ratios = Distribution Ratios (JSON) -hackforger.hackathon.dist_ratios_help = JSON array of {rank, pct} objects. Sum of pct must equal 100. -hackforger.hackathon.invalid_ratios = Invalid distribution ratios - -; Delivery -hackforger.credits.delivery = Delivery -hackforger.credits.delivery.license_key = License Key -hackforger.credits.delivery.download_link = Download Link -hackforger.credits.delivery.tracking = Tracking Number -hackforger.credits.delivery.custom = Custom - -; Key pool admin -hackforger.credits.admin.key_pool = Key Pool -hackforger.credits.admin.keys_status = %d total / %d available -hackforger.credits.admin.add_keys = Add Keys -hackforger.credits.admin.keys_textarea = One key per line -hackforger.credits.admin.add_keys_button = Add Keys -hackforger.credits.admin.existing_keys = Existing Keys -hackforger.credits.admin.key_value = Key Value -hackforger.credits.admin.key_status = Status -hackforger.credits.admin.key_order_id = Order ID -hackforger.credits.admin.key_used = Used -hackforger.credits.admin.key_available = Available -hackforger.credits.admin.no_keys = No keys in pool -hackforger.credits.admin.keys_empty = Please enter at least one key -hackforger.credits.admin.keys_added = Added %d keys to pool -hackforger.credits.admin.manage_keys = Manage Keys -hackforger.credits.admin.fulfill_mode = Fulfill Mode -hackforger.credits.admin.fulfill_manual = Manual -hackforger.credits.admin.fulfill_auto = Auto (Key Pool) - -; Batch fulfill -hackforger.credits.admin.batch_fulfill = Batch Fulfill -hackforger.credits.admin.batch_success = Successfully fulfilled %d orders -hackforger.credits.admin.batch_partial = Fulfilled %d orders, %d failed -hackforger.credits.admin.no_orders_selected = No orders selected -hackforger.credits.admin.delivery_type = Delivery Type -hackforger.credits.admin.delivery_value = Delivery Value - -; Order notifications -hackforger.credits.order_fulfilled = Your order for "%s" has been fulfilled -hackforger.credits.order_cancelled = Your order for "%s" has been cancelled -``` - -- [ ] **Step 2: Add corresponding Chinese translations** - -Append to the `[hackforger]` section of `locale_zh-CN.ini`: - -```ini -; 奖品分配 -hackforger.hackathon.prize_dist_mode = 奖品分配方式 -hackforger.hackathon.dist_winner_takes_all = 冠军独占 -hackforger.hackathon.dist_tiered = 分级分配(自定义比例) -hackforger.hackathon.dist_equal = 平均分配 -hackforger.hackathon.dist_ratios = 分配比例 (JSON) -hackforger.hackathon.dist_ratios_help = JSON 数组,格式 {rank, pct},pct 之和必须等于 100。 -hackforger.hackathon.invalid_ratios = 无效的分配比例 - -; 交付信息 -hackforger.credits.delivery = 交付信息 -hackforger.credits.delivery.license_key = 许可证密钥 -hackforger.credits.delivery.download_link = 下载链接 -hackforger.credits.delivery.tracking = 物流单号 -hackforger.credits.delivery.custom = 自定义 - -; 密钥池管理 -hackforger.credits.admin.key_pool = 密钥池 -hackforger.credits.admin.keys_status = 共 %d 个 / %d 个可用 -hackforger.credits.admin.add_keys = 添加密钥 -hackforger.credits.admin.keys_textarea = 每行一个密钥 -hackforger.credits.admin.add_keys_button = 添加密钥 -hackforger.credits.admin.existing_keys = 已有密钥 -hackforger.credits.admin.key_value = 密钥值 -hackforger.credits.admin.key_status = 状态 -hackforger.credits.admin.key_order_id = 订单 ID -hackforger.credits.admin.key_used = 已使用 -hackforger.credits.admin.key_available = 可用 -hackforger.credits.admin.no_keys = 密钥池为空 -hackforger.credits.admin.keys_empty = 请输入至少一个密钥 -hackforger.credits.admin.keys_added = 已添加 %d 个密钥到密钥池 -hackforger.credits.admin.manage_keys = 管理密钥 -hackforger.credits.admin.fulfill_mode = 交付方式 -hackforger.credits.admin.fulfill_manual = 手动交付 -hackforger.credits.admin.fulfill_auto = 自动交付(密钥池) - -; 批量交付 -hackforger.credits.admin.batch_fulfill = 批量交付 -hackforger.credits.admin.batch_success = 成功交付 %d 个订单 -hackforger.credits.admin.batch_partial = 已交付 %d 个订单,%d 个失败 -hackforger.credits.admin.no_orders_selected = 未选择订单 -hackforger.credits.admin.delivery_type = 交付类型 -hackforger.credits.admin.delivery_value = 交付内容 - -; 订单通知 -hackforger.credits.order_fulfilled = 您的「%s」订单已交付 -hackforger.credits.order_cancelled = 您的「%s」订单已取消 -``` - -- [ ] **Step 3: Also check and fill any missing existing zh-CN keys** - -Compare the existing en-US credits keys against zh-CN. Fill in any missing translations for the keys identified during exploration (overview, redeem confirmation, admin pages, etc.). - -- [ ] **Step 4: Commit** - -```bash -git add options/locale/locale_en-US.ini options/locale/locale_zh-CN.ini -git commit -m "i18n: complete credits en-US + zh-CN translations" -``` - ---- - -### Task 20: E2E Test Prompt + Report Template - -**Files:** -- Create: `docs/tests/e2e/credits-integration-fulfill-e2e.md` - -- [ ] **Step 1: Write the E2E test prompt** - -```markdown -# E2E Test: Credits Integration + Fulfill Enhancement - -**Instance:** https://hackforger.inside.h2os.cloud -**Login:** hackforger / admin1234 -**Date:** ___________ - -## Pre-requisites -- Server rebuilt and running with latest code -- Database migrated (v14g applied) - -## Test Scenarios - -### 1. Hackathon Credits Distribution -- [ ] Create a hackathon with 3 tracks: - - Track A: prize_credits=1000, mode=winner_takes_all - - Track B: prize_credits=1000, mode=tiered, ratios=[{1:50},{2:30},{3:20}] - - Track C: prize_credits=100, mode=equal -- [ ] Register 3 users, submit to each track, score submissions -- [ ] Finalize hackathon -- [ ] Check each user's /credits page: - - Track A: rank 1 user has +1000 - - Track B: rank 1 has +500, rank 2 has +300, rank 3 has +200 - - Track C: each user has equal share -- [ ] Verify transaction references show correct hackathon/track/rank format - -### 2. Manual Fulfill with Delivery (F2) -- [ ] Create a manual redeem option (cost=50) -- [ ] As user, redeem the option -> order shows "pending" -- [ ] As admin, go to /-/admin/credits/orders, find the order -- [ ] Click Fulfill, enter delivery_type="license_key", delivery_value="TEST-KEY-123" -- [ ] As user, check /credits/orders -> order shows "fulfilled" with key visible - -### 3. Auto-Fulfill Key Pool (F4) -- [ ] As admin, create redeem option with fulfill_mode="auto", cost=50 -- [ ] Go to /-/admin/credits/options/{id}/keys -- [ ] Add 3 keys via textarea (one per line) -- [ ] Verify pool shows "3 total / 3 available" -- [ ] As user, redeem the option -> order immediately "fulfilled" with first key -- [ ] As admin, pool shows "3 total / 2 available" -- [ ] Redeem again -> second key assigned -- [ ] Redeem until keys exhausted -> option auto-deactivated, error on next attempt - -### 4. Batch Fulfill (F3) -- [ ] Create manual option, have 3 users redeem -> 3 pending orders -- [ ] As admin, go to /-/admin/credits/orders -- [ ] Check 2 orders, click "Batch Fulfill" with note="March batch" -- [ ] Verify both orders show "fulfilled" -- [ ] Third order still "pending" - -### 5. Order Notification (F1) -- [ ] After fulfilling an order, check user's feed -- [ ] "order_fulfilled" event should appear targeting the order owner -- [ ] Cancel an order -> "order_cancelled" event in user's feed - -### 6. i18n (zh-CN) -- [ ] Switch language to 简体中文 -- [ ] Visit /credits -> all labels in Chinese -- [ ] Visit /-/admin/credits/options -> Chinese labels -- [ ] Visit /-/admin/credits/orders -> Chinese labels -- [ ] Visit /-/admin/credits/options/{id}/keys -> Chinese labels - -## Results - -| # | Scenario | Pass/Fail | Notes | -|---|----------|-----------|-------| -| 1 | Hackathon Credits | | | -| 2 | Manual Fulfill | | | -| 3 | Auto-Fulfill | | | -| 4 | Batch Fulfill | | | -| 5 | Order Notification | | | -| 6 | i18n zh-CN | | | - -**Overall:** __ / 6 passed -**Tester:** ___________ -``` - -- [ ] **Step 2: Commit** - -```bash -git add docs/tests/e2e/credits-integration-fulfill-e2e.md -git commit -m "docs: add E2E test prompt for credits integration + fulfill" -``` - ---- - -## Build Verification - -### Task 21: Full Build + Test Sweep - -- [ ] **Step 1: Build backend** - -Run: `cd /Users/h2oslabs/Workspace/hackforger/.claude/worktrees/phase2-credits && TAGS="bindata sqlite sqlite_unlock_notify" make backend` -Expected: compiles with no errors - -- [ ] **Step 2: Run all hackforger tests** - -Run: `go test ./models/hackforger/... -v && go test ./services/hackforger/... -v` -Expected: all PASS - -- [ ] **Step 3: Build frontend (if templates changed)** - -Run: `make frontend` -Expected: compiles with no errors - -- [ ] **Step 4: Final commit if any fixups needed** - -```bash -git add -A && git commit -m "fix: build and test fixups for credits integration" -``` diff --git a/docs/superpowers/plans/2026-03-30-phase2-judge-system.md b/docs/superpowers/plans/2026-03-30-phase2-judge-system.md deleted file mode 100644 index ccdb11f23f..0000000000 --- a/docs/superpowers/plans/2026-03-30-phase2-judge-system.md +++ /dev/null @@ -1,1378 +0,0 @@ -# Phase 2 Judge System Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Upgrade hackathon judging from single-score MVP to multi-criteria scoring with per-track judges, organizer-defined rubrics, interactive Vue scoring UI, and two-step finalize. - -**Architecture:** Bottom-up (Models → Services → Routers → Templates → Vue). New `hackathon_judge_criteria` and `hackathon_track_criteria` tables. Existing `hackathon_judge` gains `TrackID`, `hackathon_judge_score` gains `CriteriaID`. Service layer adds criteria management, weighted rank calculation, and two-step finalize. JudgeScoreCard.vue provides interactive scoring. - -**Tech Stack:** Go (XORM ORM, go-chi router), Go templates (Fomantic UI), Vue 3 (Options API, lazy-load), SQLite. - -**Spec:** `docs/superpowers/specs/2026-03-30-phase2-judge-system-design.md` - -**Merge dependency:** Feed refactor (Line B) merges first. Use current `PublishHackforgerAction` format during development; update 2 call sites at merge time. - ---- - -## File Map - -### New Files -| File | Responsibility | -|------|---------------| -| `models/hackforger/hackathon_judge_criteria.go` | Criteria model + CRUD + `ErrCriteriaNotExist` | -| `models/hackforger/hackathon_track_criteria.go` | Track criteria override model + CRUD + `SeedTrackCriteria` | -| `services/hackforger/hackathon_criteria.go` | Criteria lifecycle + `EffectiveCriteria` + `GetEffectiveRubric` | -| `web_src/js/components/hackforger/JudgeScoreCard.vue` | Interactive scoring UI | - -### Modified Files -| File | Changes | -|------|---------| -| `models/hackforger/hackathon_judge.go` | Add `TrackID`, update UNIQUE, update `ErrDuplicateJudge`, modify all CRUD signatures | -| `models/hackforger/hackathon_judge_score.go` | Add `CriteriaID`, add `UNIQUE(s)` tags, update `ErrDuplicateScore`, add `ListScoresByJudgeAndSubmission`, rewrite `HasAllJudgesScored` | -| `services/hackforger/hackathon_judge.go` | Replace `SubmitScore` → `SubmitScores`, rewrite `CalculateRanks`, add `PersistRanks`, add typed errors | -| `services/hackforger/hackathon.go` | Add criteria check to `StartJudging`, replace `FinalizeHackathon` with `PreviewFinalize` + `ConfirmFinalize`, add `ErrNoCriteria` + `ErrInvalidHackathonPhase` | -| `routers/web/hackforger/hackathon.go` | New handlers: criteria CRUD, track criteria, finalize preview/confirm, rewrite judge page/score, update manage judge | -| `routers/api/v1/hackforger/hackathon_judge.go` | Update forms, add criteria endpoints, update score endpoint, add finalize endpoints | -| `routers/web/web.go:534-549` | Update manage group routes (criteria, finalize), update judge group routes | -| `routers/api/v1/api.go:1788-1793` | Update judge/score API routes, add criteria routes | -| `templates/hackforger/hackathon/manage.tmpl` | Add criteria section, track criteria overrides, enhanced judge section, finalize preview | -| `templates/hackforger/hackathon/judge.tmpl` | Rewrite: Vue mount point + data attributes | -| `templates/hackforger/hackathon/leaderboard.tmpl` | Per-track tabs, criteria breakdown columns | -| `templates/hackforger/hackathon/view.tmpl` | Update judge button visibility | -| `web_src/js/features/hackforger/init.js` | Add JudgeScoreCard lazy-load mount | -| `options/locale/locale_en-US.ini` | Add ~35 i18n keys under `[hackforger]` | -| `options/locale/locale_zh-CN.ini` | Add ~35 i18n keys under `[hackforger]` | - ---- - -## Chunk 1: Model Layer — New Tables + Modified Tables - -### Task 1: Create `hackathon_judge_criteria` model - -**Files:** -- Create: `models/hackforger/hackathon_judge_criteria.go` - -- [ ] **Step 1: Create the criteria model file** - -Create `models/hackforger/hackathon_judge_criteria.go` with: -- `HackathonJudgeCriteria` struct (ID, HackathonID, Name, Description, MaxScore, Weight, SortOrder, CreatedUnix, UpdatedUnix) -- `init()` with `db.RegisterModel` -- `ErrCriteriaNotExist` typed error with `IsErrCriteriaNotExist()` and `Unwrap() → util.ErrNotExist` -- CRUD functions: `CreateCriteria`, `UpdateCriteria`, `DeleteCriteria`, `ListCriteriaByHackathon`, `GetCriteriaByID`, `CountCriteriaByHackathon` - -Reference `models/hackforger/hackathon_track.go` for the pattern (struct, init, error type, CRUD). - -- [ ] **Step 2: Verify compilation** - -Run: `TAGS="bindata sqlite sqlite_unlock_notify" go build ./models/hackforger/...` -Expected: Clean compilation. - -- [ ] **Step 3: Commit** - -```bash -git add models/hackforger/hackathon_judge_criteria.go -git commit -m "feat(judge): add HackathonJudgeCriteria model with CRUD" -``` - -### Task 2: Create `hackathon_track_criteria` model - -**Files:** -- Create: `models/hackforger/hackathon_track_criteria.go` - -- [ ] **Step 1: Create the track criteria model file** - -Create `models/hackforger/hackathon_track_criteria.go` with: -- `HackathonTrackCriteria` struct (ID, TrackID `UNIQUE(s)`, CriteriaID `UNIQUE(s)`, Enabled, Weight) -- `init()` with `db.RegisterModel` -- CRUD functions: `CreateTrackCriteria`, `UpdateTrackCriteria`, `ListTrackCriteria`, `GetTrackCriteria` -- `SeedTrackCriteria(ctx, trackID int64, criteria []*HackathonJudgeCriteria) error` — idempotent bulk-insert (check existence before insert per criteria+track pair) - -- [ ] **Step 2: Verify compilation** - -Run: `TAGS="bindata sqlite sqlite_unlock_notify" go build ./models/hackforger/...` -Expected: Clean compilation. - -- [ ] **Step 3: Commit** - -```bash -git add models/hackforger/hackathon_track_criteria.go -git commit -m "feat(judge): add HackathonTrackCriteria model with CRUD and idempotent seed" -``` - -### Task 3: Modify `hackathon_judge.go` — add TrackID - -**Files:** -- Modify: `models/hackforger/hackathon_judge.go` - -- [ ] **Step 1: Update the struct and error type** - -In `models/hackforger/hackathon_judge.go`: -- Add `TrackID int64 \`xorm:"UNIQUE(s) INDEX NOT NULL"\`` to `HackathonJudge` struct -- Update `ErrDuplicateJudge` to include `TrackID int64` field -- Update `ErrDuplicateJudge.Error()` to include TrackID in message - -- [ ] **Step 2: Update CRUD function signatures** - -- `AddJudge(ctx, hackathonID, trackID, userID int64)` — add trackID to existence check and insert -- `RemoveJudge(ctx, hackathonID, trackID, userID int64)` — add trackID to WHERE -- `ListJudges(ctx, hackathonID int64, trackID ...int64)` — optional trackID filter: if len(trackID) > 0, add `AND track_id = ?` -- `IsJudge(ctx, hackathonID, trackID, userID int64)` — add trackID to WHERE -- Add `IsJudgeForAnyTrack(ctx, hackathonID, userID int64) (bool, error)` — checks `hackathon_id=? AND user_id=?` without track filter -- `CountJudges(ctx, hackathonID int64, trackID ...int64)` — optional trackID filter - -- [ ] **Step 3: Verify compilation** - -Run: `TAGS="bindata sqlite sqlite_unlock_notify" go build ./models/hackforger/...` -Expected: Compilation errors in callers (service/router layers). This is expected — we'll fix them in later tasks. - -- [ ] **Step 4: Commit** - -```bash -git add models/hackforger/hackathon_judge.go -git commit -m "feat(judge): add TrackID to HackathonJudge model, update CRUD signatures" -``` - -### Task 4: Modify `hackathon_judge_score.go` — add CriteriaID - -**Files:** -- Modify: `models/hackforger/hackathon_judge_score.go` - -- [ ] **Step 1: Update the struct and error type** - -In `models/hackforger/hackathon_judge_score.go`: -- Add `UNIQUE(s)` tags to `SubmissionID`, `JudgeID` fields -- Add `CriteriaID int64 \`xorm:"UNIQUE(s) INDEX NOT NULL"\`` -- Update `ErrDuplicateScore` to include `CriteriaID int64` -- Update `ErrDuplicateScore.Error()` to include CriteriaID - -- [ ] **Step 2: Update CRUD functions** - -- `GetScore(ctx, judgeID, submissionID, criteriaID int64)` — add `criteria_id = ?` to WHERE -- `CreateScore` — update duplicate check WHERE to include `criteria_id = ?` -- `ListScoresBySubmission` — unchanged (returns all criteria scores for a submission) -- Add `ListScoresByJudgeAndSubmission(ctx, judgeID, submissionID int64) ([]*HackathonJudgeScore, error)` — WHERE `judge_id=? AND submission_id=?` -- Rewrite `HasAllJudgesScored(ctx, hackathonID, trackID, submissionID int64) (bool, error)`: - 1. Get judges for track: `ListJudges(ctx, hackathonID, trackID)` - 2. Get enabled criteria IDs for track (query `hackathon_track_criteria` WHERE `track_id=? AND enabled=true`, join with `hackathon_judge_criteria`) - 3. For each judge: count scores WHERE `judge_id=? AND submission_id=? AND criteria_id IN (?)` - 4. If any judge's count < len(enabled criteria), return false - -Note: `HasAllJudgesScored` now depends on `hackathon_track_criteria` and `hackathon_judge_criteria` tables (created in Tasks 1-2). This cross-model dependency is acceptable in the models layer since it's a pure data query. - -- [ ] **Step 3: Verify compilation** - -Run: `TAGS="bindata sqlite sqlite_unlock_notify" go build ./models/hackforger/...` -Expected: Compilation errors in callers (service layer). Expected — fixed in Chunk 2. - -- [ ] **Step 4: Commit** - -```bash -git add models/hackforger/hackathon_judge_score.go -git commit -m "feat(judge): add CriteriaID to HackathonJudgeScore, rewrite HasAllJudgesScored" -``` - -### Task 5: Add typed errors to models - -**Files:** -- Modify: `models/hackforger/hackathon.go` (add error types near existing errors) - -- [ ] **Step 1: Add model-layer typed errors** - -Add to `models/hackforger/hackathon.go` (after existing `ErrHackathonSlugAlreadyExist`): - -```go -type ErrInvalidHackathonPhase struct { - HackathonID int64 - Current HackathonStatus - Expected HackathonStatus -} - -func IsErrInvalidHackathonPhase(err error) bool { - _, ok := err.(ErrInvalidHackathonPhase) - return ok -} - -func (err ErrInvalidHackathonPhase) Error() string { - return fmt.Sprintf("invalid hackathon phase [id: %d, current: %d, expected: %d]", err.HackathonID, err.Current, err.Expected) -} - -func (err ErrInvalidHackathonPhase) Unwrap() error { - return util.ErrInvalidArgument -} - -type ErrNotJudge struct { - UserID int64 - HackathonID int64 - TrackID int64 -} - -func IsErrNotJudge(err error) bool { - _, ok := err.(ErrNotJudge) - return ok -} - -func (err ErrNotJudge) Error() string { - return fmt.Sprintf("user is not a judge [user_id: %d, hackathon_id: %d, track_id: %d]", err.UserID, err.HackathonID, err.TrackID) -} - -func (err ErrNotJudge) Unwrap() error { - return util.ErrPermissionDenied -} - -type ErrNoCriteria struct { - HackathonID int64 -} - -func IsErrNoCriteria(err error) bool { - _, ok := err.(ErrNoCriteria) - return ok -} - -func (err ErrNoCriteria) Error() string { - return fmt.Sprintf("no scoring criteria defined [hackathon_id: %d]", err.HackathonID) -} - -func (err ErrNoCriteria) Unwrap() error { - return util.ErrInvalidArgument -} -``` - -- [ ] **Step 2: Verify compilation** - -Run: `TAGS="bindata sqlite sqlite_unlock_notify" go build ./models/hackforger/...` -Expected: Clean compilation. - -- [ ] **Step 3: Commit** - -```bash -git add models/hackforger/hackathon.go -git commit -m "feat(judge): add ErrInvalidHackathonPhase, ErrNotJudge, ErrNoCriteria typed errors" -``` - ---- - -## Chunk 2: Service Layer - -### Task 6: Create criteria service - -**Files:** -- Create: `services/hackforger/hackathon_criteria.go` - -- [ ] **Step 1: Create the criteria service file** - -Create `services/hackforger/hackathon_criteria.go` with: - -```go -package hackforger - -// EffectiveCriteria represents a criterion with track-level weight resolution. -type EffectiveCriteria struct { - CriteriaID int64 - Name string - Description string - MaxScore float64 - Weight float64 - SortOrder int -} -``` - -Functions: -- `AddCriteria(ctx, hackathonID, name, description, maxScore, weight, sortOrder)` — check hackathon status (Draft/Open only), create criterion, seed all existing tracks using `SeedTrackCriteria` -- `UpdateCriteria(ctx, criteriaID, name, description, maxScore, weight, sortOrder)` — check hackathon status, update -- `RemoveCriteria(ctx, criteriaID)` — check hackathon status, wrap in `db.WithTx`: delete criterion + cascade delete track_criteria + cascade delete judge_scores for that criteria -- `SetTrackCriteriaOverride(ctx, trackID, criteriaID, enabled, weight)` — validate weight > 0 if enabled, update track_criteria -- `GetEffectiveRubric(ctx, trackID)` — join `hackathon_judge_criteria` with `hackathon_track_criteria` on `criteria_id`, filter `enabled=true`, resolve weight (track override if > 0, else criteria default), order by `sort_order` - -Status check helper: load criteria → get hackathon → verify status is Draft or Open. If not, return `ErrInvalidHackathonPhase`. - -Also add service-layer typed errors: -```go -type ErrScoreOutOfRange struct { CriteriaID int64; Score, MaxScore float64 } -type ErrIncompleteRubric struct { Missing []int64 } -``` -With `IsErr*()` and `Unwrap()` for each. - -- [ ] **Step 2: Verify compilation** - -Run: `TAGS="bindata sqlite sqlite_unlock_notify" go build ./services/hackforger/...` -Expected: Clean compilation (may still have errors from Task 3-4 caller updates — address next). - -- [ ] **Step 3: Commit** - -```bash -git add services/hackforger/hackathon_criteria.go -git commit -m "feat(judge): add criteria service with EffectiveRubric, CRUD, and typed errors" -``` - -### Task 7: Update hackathon_judge.go service — SubmitScores + CalculateRanks - -**Files:** -- Modify: `services/hackforger/hackathon_judge.go` - -- [ ] **Step 1: Add CriteriaScore type and replace SubmitScore with SubmitScores** - -In `services/hackforger/hackathon_judge.go`: - -Replace the existing `SubmitScore` function with: - -```go -type CriteriaScore struct { - CriteriaID int64 - Score float64 - Comment string -} - -func SubmitScores(ctx context.Context, judgeID, submissionID int64, scores []CriteriaScore) error { - // 1. Load submission - sub, err := hackforger_model.GetSubmissionByID(ctx, submissionID) - // 2. Load hackathon, verify status == Judging - // 3. IsJudge(ctx, h.ID, sub.TrackID, judgeID) — if not, return ErrNotJudge - // 4. GetEffectiveRubric(ctx, sub.TrackID) — get enabled criteria - // 5. Build map of criteria: validate all enabled criteria present, check score ranges - // 6. db.WithTx: for each score, get existing → update or create - // 7. PublishHackforgerAction (use current format, TODO for feed refactor) -} -``` - -- [ ] **Step 2: Rewrite CalculateRanks, add PersistRanks** - -Replace the existing `CalculateRanks` function: - -```go -type RankedSubmission struct { - SubmissionID int64 - Title string - UserID int64 - TrackID int64 - WeightedTotal float64 - CriteriaScores map[int64]float64 - Rank int -} - -// CalculateRanks — pure computation, no DB writes. -func CalculateRanks(ctx context.Context, hackathonID int64) (map[int64][]RankedSubmission, error) { - tracks, err := hackforger_model.ListTracksByHackathon(ctx, hackathonID) - if err != nil { return nil, err } - result := make(map[int64][]RankedSubmission) - for _, track := range tracks { - rubric, err := GetEffectiveRubric(ctx, track.ID) - if err != nil { return nil, err } - if len(rubric) == 0 { continue } - subs, _, err := hackforger_model.ListSubmissions(ctx, hackforger_model.ListSubmissionsOptions{ - HackathonID: hackathonID, TrackID: track.ID, - }) - if err != nil { return nil, err } - // For each sub: get all scores, compute per-criteria averages, - // compute weighted total = sum(avg * weight) / sum(weights) - // Sort by weighted total desc, assign ranks 1-based - result[track.ID] = ranked - } - return result, nil -} - -// PersistRanks writes rankings to hackathon_submission table. -func PersistRanks(ctx context.Context, rankings map[int64][]RankedSubmission) error { - var allRankings []hackforger_model.SubmissionRanking - for _, subs := range rankings { - for _, s := range subs { - allRankings = append(allRankings, hackforger_model.SubmissionRanking{ - SubmissionID: s.SubmissionID, TotalScore: s.WeightedTotal, Rank: s.Rank, - }) - } - } - return hackforger_model.UpdateSubmissionRanks(ctx, allRankings) -} -``` - -- [ ] **Step 3: Verify compilation** - -Run: `TAGS="bindata sqlite sqlite_unlock_notify" go build ./services/hackforger/...` -Expected: Compilation errors in router callers referencing old `SubmitScore`. Expected — fixed in Chunk 3. - -- [ ] **Step 4: Commit** - -```bash -git add services/hackforger/hackathon_judge.go -git commit -m "feat(judge): replace SubmitScore with multi-criteria SubmitScores, rewrite CalculateRanks" -``` - -### Task 8: Update hackathon.go service — StartJudging validation + Two-Step Finalize - -**Files:** -- Modify: `services/hackforger/hackathon.go` - -- [ ] **Step 1: Add criteria check to StartJudging** - -In `services/hackforger/hackathon.go`, function `StartJudging` (line ~167): - -After the existing `subCount == 0` check, add: -```go -criteriaCount, err := hackforger_model.CountCriteriaByHackathon(ctx, h.ID) -if err != nil { return err } -if criteriaCount == 0 { - return hackforger_model.ErrNoCriteria{HackathonID: h.ID} -} -``` - -- [ ] **Step 2: Replace FinalizeHackathon with PreviewFinalize + ConfirmFinalize** - -Replace the `FinalizeHackathon` function (lines ~186-212) with: - -```go -func PreviewFinalize(ctx context.Context, hackathonID int64) (map[int64][]RankedSubmission, error) { - h, err := hackforger_model.GetHackathonByID(ctx, hackathonID) - if err != nil { return nil, err } - if h.Status != hackforger_model.HackathonStatusJudging { - return nil, hackforger_model.ErrInvalidHackathonPhase{ - HackathonID: h.ID, Current: h.Status, Expected: hackforger_model.HackathonStatusJudging, - } - } - return CalculateRanks(ctx, hackathonID) -} - -func ConfirmFinalize(ctx context.Context, doerID int64, h *hackforger_model.Hackathon) error { - if h.Status != hackforger_model.HackathonStatusJudging { - return hackforger_model.ErrInvalidHackathonPhase{...} - } - tagTrackRepos(ctx, doerID, h.ID, "submission-deadline", "Submission deadline snapshot") - rankings, err := CalculateRanks(ctx, h.ID) - if err != nil { return err } - if err := PersistRanks(ctx, rankings); err != nil { return err } - if err := hackforger_model.UpdateHackathonStatus(ctx, h.ID, hackforger_model.HackathonStatusFinished); err != nil { return err } - createTrackReleases(ctx, doerID, h) - // Build winners summary from rankings - trackWinners := make([]map[string]any, 0) - for trackID, subs := range rankings { - if len(subs) > 0 { - trackWinners = append(trackWinners, map[string]any{ - "track_id": trackID, "winner_submission_id": subs[0].SubmissionID, "winner_user_id": subs[0].UserID, - }) - } - } - // NOTE: Deliberately using HackforgerActionContent (not HackforgerPhaseContent) - // because ActionHackathonFinalized now carries results summary, not phase transition info. - // Feed rendering templates will be updated by the feed refactor (merges first). - // TODO: update to new feed API format after feed refactor merges - _ = PublishHackforgerAction(ctx, &HackforgerActionOpts{ - ActUserID: doerID, OpType: hackforger_model.ActionHackathonFinalized, - AudienceType: AudienceGlobal, - Content: hackforger_model.HackforgerActionContent{ - EntityType: "hackathon", EntityID: h.ID, EntityName: h.Name, EntitySlug: h.Slug, - Extra: map[string]any{"tracks": trackWinners}, - }, - }) - return nil -} -``` - -- [ ] **Step 3: Also replace fmt.Errorf in StartHacking** - -Replace `fmt.Errorf("hackathon must be in Open status...")` in `StartHacking` (line ~155) with: -```go -return hackforger_model.ErrInvalidHackathonPhase{HackathonID: h.ID, Current: h.Status, Expected: hackforger_model.HackathonStatusOpen} -``` - -Do the same for `StartJudging` (line ~169) and `CancelHackathon` where `fmt.Errorf` is used. - -- [ ] **Step 4: Verify compilation of services** - -Run: `TAGS="bindata sqlite sqlite_unlock_notify" go build ./services/hackforger/...` -Expected: May have errors from router callers — expected. - -- [ ] **Step 5: Commit** - -```bash -git add services/hackforger/hackathon.go -git commit -m "feat(judge): two-step finalize, criteria validation in StartJudging, typed errors" -``` - -### Task 9: Update CreateTrackWithRepo to auto-seed criteria - -**Files:** -- Modify: `services/hackforger/hackathon.go` - -- [ ] **Step 1: Add auto-seed call after track creation** - -Find the `CreateTrackWithRepo` function. After the track is created and repo is initialized, add: - -```go -// Auto-seed track criteria for any existing hackathon criteria -criteria, _ := hackforger_model.ListCriteriaByHackathon(ctx, hackathonID) -if len(criteria) > 0 { - _ = hackforger_model.SeedTrackCriteria(ctx, track.ID, criteria) -} -``` - -- [ ] **Step 2: Verify compilation** - -Run: `TAGS="bindata sqlite sqlite_unlock_notify" go build ./services/hackforger/...` - -- [ ] **Step 3: Commit** - -```bash -git add services/hackforger/hackathon.go -git commit -m "feat(judge): auto-seed track criteria when creating tracks" -``` - ---- - -## Chunk 3: Web Routes + API Routes - -### Task 10: Update web route registration in web.go - -**Files:** -- Modify: `routers/web/web.go:534-549` - -**Note:** This task references handler functions defined in Task 11. Do NOT attempt to compile after this task — complete Task 11 first, then compile together. - -- [ ] **Step 1: Update manage group routes** - -In `routers/web/web.go`, within the `/hackathon/{slug}/manage` group (line 534): - -Replace: -```go -m.Post("/finalize", hackforger_web.ManagePhasePost) -``` -With: -```go -m.Get("/finalize-preview", hackforger_web.FinalizePreview) -m.Post("/finalize-confirm", hackforger_web.FinalizeConfirm) -``` - -Add new routes inside the manage group: -```go -m.Post("/criteria", hackforger_web.ManageCriteriaPost) -m.Post("/criteria/{cid}/update", hackforger_web.ManageCriteriaUpdatePost) -m.Post("/criteria/{cid}/delete", hackforger_web.ManageCriteriaDeletePost) -m.Post("/tracks/{tid}/criteria", hackforger_web.ManageTrackCriteriaPost) -``` - -- [ ] **Step 2: Update judge group routes** - -Replace: -```go -m.Post("/{sid}/score", hackforger_web.JudgeScorePost) -``` -With: -```go -m.Post("/{sid}/scores", hackforger_web.JudgeScoresPost) -``` - -- [ ] **Step 3: Commit** - -```bash -git add routers/web/web.go -git commit -m "feat(judge): update web route registration for criteria, finalize, scores" -``` - -### Task 11: Add criteria and finalize web handlers - -**Files:** -- Modify: `routers/web/hackforger/hackathon.go` - -- [ ] **Step 1: Add criteria management handlers** - -Add to `routers/web/hackforger/hackathon.go`: - -```go -func ManageCriteriaPost(ctx *context.Context) { - h := loadHackathon(ctx) - if h == nil { return } - name := ctx.FormString("name") - description := ctx.FormString("description") - maxScore, _ := strconv.ParseFloat(ctx.FormString("max_score"), 64) - weight, _ := strconv.ParseFloat(ctx.FormString("weight"), 64) - sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order")) - if maxScore <= 0 { maxScore = 10 } - if weight <= 0 { weight = 25 } - if err := hackforger_service.AddCriteria(ctx, h.ID, name, description, maxScore, weight, sortOrder); err != nil { - if hackforger_model.IsErrInvalidHackathonPhase(err) { - ctx.Flash.Error(ctx.Tr("hackforger.hackathon.error.criteria_locked")) - } else { - log.Error("AddCriteria: %v", err) - ctx.Flash.Error(ctx.Tr("hackforger.hackathon.error.internal")) - } - } - ctx.Redirect("/hackathon/" + h.Slug + "/manage") -} - -func ManageCriteriaUpdatePost(ctx *context.Context) { /* similar pattern */ } -func ManageCriteriaDeletePost(ctx *context.Context) { /* similar pattern */ } -func ManageTrackCriteriaPost(ctx *context.Context) { /* SetTrackCriteriaOverride */ } -``` - -- [ ] **Step 2: Add finalize preview and confirm handlers** - -```go -func FinalizePreview(ctx *context.Context) { - h := loadHackathon(ctx) - if h == nil { return } - rankings, err := hackforger_service.PreviewFinalize(ctx, h.ID) - if err != nil { - if hackforger_model.IsErrInvalidHackathonPhase(err) { - ctx.Flash.Error(ctx.Tr("hackforger.hackathon.error.invalid_phase")) - } else { - log.Error("PreviewFinalize: %v", err) - ctx.Flash.Error(ctx.Tr("hackforger.hackathon.error.internal")) - } - ctx.Redirect("/hackathon/" + h.Slug + "/manage") - return - } - // Load track names for display - tracks, _ := hackforger_model.ListTracksByHackathon(ctx, h.ID) - ctx.Data["Title"] = ctx.Tr("hackforger.hackathon.manage.finalize_preview") - ctx.Data["Hackathon"] = h - ctx.Data["Rankings"] = rankings - ctx.Data["Tracks"] = tracks - // Load effective rubric per track for column headers - rubrics := make(map[int64][]*hackforger_service.EffectiveCriteria) - for _, t := range tracks { - r, _ := hackforger_service.GetEffectiveRubric(ctx, t.ID) - rubrics[t.ID] = r - } - ctx.Data["Rubrics"] = rubrics - ctx.HTML(http.StatusOK, tplManage) // reuse manage template with preview section -} - -func FinalizeConfirm(ctx *context.Context) { - h := loadHackathon(ctx) - if h == nil { return } - h, _ = hackforger_model.GetHackathonByID(ctx, h.ID) // reload fresh - if err := hackforger_service.ConfirmFinalize(ctx, ctx.Doer.ID, h); err != nil { - if hackforger_model.IsErrInvalidHackathonPhase(err) { - ctx.Flash.Error(ctx.Tr("hackforger.hackathon.error.invalid_phase")) - } else { - log.Error("ConfirmFinalize: %v", err) - ctx.Flash.Error(ctx.Tr("hackforger.hackathon.error.internal")) - } - } else { - ctx.Flash.Success(ctx.Tr("hackforger.hackathon.manage.finalize_success")) - } - ctx.Redirect("/hackathon/" + h.Slug + "/manage") -} -``` - -- [ ] **Step 3: Remove "finalize" case from ManagePhasePost** - -In `ManagePhasePost` (line ~366), remove: -```go -case "finalize": - err = hackforger_service.FinalizeHackathon(ctx, ctx.Doer.ID, h) -``` - -Add `IsErrNoCriteria` handling to the error block after the switch: -```go -if hackforger_model.IsErrNoCriteria(err) { - ctx.Flash.Error(ctx.Tr("hackforger.hackathon.error.no_criteria")) -} -``` - -- [ ] **Step 4: Commit** - -```bash -git add routers/web/hackforger/hackathon.go -git commit -m "feat(judge): add criteria CRUD, finalize preview/confirm web handlers" -``` - -### Task 12: Update judge page and scores handler - -**Files:** -- Modify: `routers/web/hackforger/hackathon.go` - -- [ ] **Step 1: Rewrite JudgePage with permission check and data loading** - -Replace existing `JudgePage` function (line ~456): - -```go -func JudgePage(ctx *context.Context) { - h := loadHackathon(ctx) - if h == nil { return } - if h.Status != hackforger_model.HackathonStatusJudging { - ctx.Flash.Error(ctx.Tr("hackforger.hackathon.error.invalid_phase")) - ctx.Redirect("/hackathon/" + h.Slug) - return - } - // Permission check - isJudge, _ := hackforger_model.IsJudgeForAnyTrack(ctx, h.ID, ctx.Doer.ID) - if !isJudge { - ctx.Flash.Error(ctx.Tr("hackforger.hackathon.error.not_judge")) - ctx.Redirect("/hackathon/" + h.Slug) - return - } - // Load tracks where user is judge - allJudges, _ := hackforger_model.ListJudges(ctx, h.ID) - var judgeTracks []int64 - for _, j := range allJudges { - if j.UserID == ctx.Doer.ID { judgeTracks = append(judgeTracks, j.TrackID) } - } - tracks, _ := hackforger_model.ListTracksByHackathon(ctx, h.ID) - // Filter to judge's tracks, load submissions + rubrics + existing scores per track - // JSON-encode into data attributes for Vue mount - // ... (build tracksJSON, subsJSON, rubricsJSON) ... - ctx.Data["Title"] = ctx.Tr("hackforger.hackathon.judge") - ctx.Data["Hackathon"] = h - ctx.Data["TracksJSON"] = tracksJSON - ctx.Data["SubmissionsJSON"] = subsJSON - ctx.Data["RubricsJSON"] = rubricsJSON - ctx.HTML(http.StatusOK, tplJudge) -} -``` - -- [ ] **Step 2: Replace JudgeScorePost with JudgeScoresPost (JSON handler)** - -First, add `"encoding/json"` and `"fmt"` to the import block of `routers/web/hackforger/hackathon.go` (if not already present). - -Replace existing `JudgeScorePost` (line ~473): - -```go -func JudgeScoresPost(ctx *context.Context) { - h := loadHackathon(ctx) - if h == nil { - ctx.JSON(http.StatusNotFound, map[string]string{"message": "hackathon not found"}) - return - } - sid := ctx.ParamsInt64(":sid") - // Parse JSON body - type scoreReq struct { - Scores []struct { - CriteriaID int64 `json:"criteria_id"` - Score float64 `json:"score"` - Comment string `json:"comment"` - } `json:"scores"` - } - var req scoreReq - if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil { - ctx.JSON(http.StatusBadRequest, map[string]string{"message": "invalid request body"}) - return - } - scores := make([]hackforger_service.CriteriaScore, len(req.Scores)) - for i, s := range req.Scores { - scores[i] = hackforger_service.CriteriaScore{CriteriaID: s.CriteriaID, Score: s.Score, Comment: s.Comment} - } - if err := hackforger_service.SubmitScores(ctx, ctx.Doer.ID, sid, scores); err != nil { - msg := "Internal error" - status := http.StatusInternalServerError - switch { - case hackforger_model.IsErrNotJudge(err): - msg = ctx.Tr("hackforger.hackathon.error.not_judge"); status = http.StatusForbidden - case hackforger_service.IsErrIncompleteRubric(err): - msg = ctx.Tr("hackforger.hackathon.error.incomplete_rubric"); status = http.StatusBadRequest - case hackforger_service.IsErrScoreOutOfRange(err): - e := err.(hackforger_service.ErrScoreOutOfRange) - msg = ctx.Tr("hackforger.hackathon.error.score_out_of_range", fmt.Sprintf("%.0f", e.MaxScore)); status = http.StatusBadRequest - } - ctx.JSON(status, map[string]string{"message": msg}) - return - } - ctx.JSON(http.StatusOK, map[string]string{"message": "ok"}) -} -``` - -- [ ] **Step 3: Update ManageJudgePost to use username + trackID** - -Replace existing `ManageJudgePost` (line ~432): - -```go -func ManageJudgePost(ctx *context.Context) { - h := loadHackathon(ctx) - if h == nil { return } - username := ctx.FormString("username") - trackID, _ := strconv.ParseInt(ctx.FormString("track_id"), 10, 64) - u, err := user_model.GetUserByName(ctx, username) - if err != nil { - ctx.Flash.Error(ctx.Tr("hackforger.hackathon.error.user_not_found", username)) - ctx.Redirect("/hackathon/" + h.Slug + "/manage") - return - } - if err := hackforger_model.AddJudge(ctx, h.ID, trackID, u.ID); err != nil { - if hackforger_model.IsErrDuplicateJudge(err) { - ctx.Flash.Error(ctx.Tr("hackforger.hackathon.error.duplicate_judge")) - } else { - log.Error("AddJudge: %v", err) - ctx.Flash.Error(ctx.Tr("hackforger.hackathon.error.internal")) - } - } - ctx.Redirect("/hackathon/" + h.Slug + "/manage") -} -``` - -Update `ManageJudgeRemovePost`: - -```go -func ManageJudgeRemovePost(ctx *context.Context) { - h := loadHackathon(ctx) - if h == nil { return } - userID := ctx.ParamsInt64(":uid") - trackID, _ := strconv.ParseInt(ctx.FormString("track_id"), 10, 64) - if err := hackforger_model.RemoveJudge(ctx, h.ID, trackID, userID); err != nil { - log.Error("RemoveJudge: %v", err) - ctx.Flash.Error(ctx.Tr("hackforger.hackathon.error.internal")) - } - ctx.Redirect("/hackathon/" + h.Slug + "/manage") -} -``` - -Note: The manage.tmpl judge remove form (Task 15, Step 3) must include a hidden `` in each remove button's form. - -- [ ] **Step 4: Update ManageHackathon to load criteria data** - -In the existing `ManageHackathon` handler, add data loading: - -```go -criteria, _ := hackforger_model.ListCriteriaByHackathon(ctx, h.ID) -ctx.Data["Criteria"] = criteria -// Track criteria overrides -trackCriteria := make(map[int64][]*hackforger_model.HackathonTrackCriteria) -for _, t := range tracks { - tc, _ := hackforger_model.ListTrackCriteria(ctx, t.ID) - trackCriteria[t.ID] = tc -} -ctx.Data["TrackCriteria"] = trackCriteria -``` - -- [ ] **Step 5: Update ViewHackathon to use IsJudgeForAnyTrack** - -In `ViewHackathon` (line ~207), replace: -```go -isJudge, _ := hackforger_model.IsJudge(ctx, h.ID, ctx.Doer.ID) -``` -With: -```go -isJudge, _ := hackforger_model.IsJudgeForAnyTrack(ctx, h.ID, ctx.Doer.ID) -``` - -- [ ] **Step 6: Update Leaderboard handler** - -Enhance `Leaderboard` function to group submissions by track, load effective rubric per track for breakdown columns: - -```go -func Leaderboard(ctx *context.Context) { - h := loadHackathon(ctx) - if h == nil { return } - tracks, _ := hackforger_model.ListTracksByHackathon(ctx, h.ID) - subsByTrack := make(map[int64][]*hackforger_model.HackathonSubmission) - for _, t := range tracks { - subs, _, _ := hackforger_model.ListSubmissions(ctx, hackforger_model.ListSubmissionsOptions{ - HackathonID: h.ID, TrackID: t.ID, - }) - subsByTrack[t.ID] = subs - } - // Load effective rubric per track for criteria breakdown columns - rubrics := make(map[int64][]*hackforger_service.EffectiveCriteria) - for _, t := range tracks { - r, _ := hackforger_service.GetEffectiveRubric(ctx, t.ID) - rubrics[t.ID] = r - } - ctx.Data["Title"] = ctx.Tr("hackforger.hackathon.leaderboard") - ctx.Data["Hackathon"] = h - ctx.Data["Tracks"] = tracks - ctx.Data["SubsByTrack"] = subsByTrack - ctx.Data["Rubrics"] = rubrics - ctx.Data["ShowBreakdown"] = h.Status == hackforger_model.HackathonStatusFinished - ctx.HTML(http.StatusOK, tplLeaderboard) -} -``` - -- [ ] **Step 7: Verify compilation of web routers** - -Run: `TAGS="bindata sqlite sqlite_unlock_notify" go build ./routers/web/...` - -- [ ] **Step 8: Commit** - -```bash -git add routers/web/hackforger/hackathon.go -git commit -m "feat(judge): rewrite judge/manage/leaderboard handlers for multi-criteria" -``` - -### Task 13: Update API routes and handlers - -**Files:** -- Modify: `routers/api/v1/hackforger/hackathon_judge.go` -- Modify: `routers/api/v1/api.go:1788-1793` - -- [ ] **Step 1: Update API forms and handlers** - -In `routers/api/v1/hackforger/hackathon_judge.go`: - -Update `AddJudgeForm`: -```go -type AddJudgeForm struct { - UserID int64 `json:"user_id" binding:"Required"` - TrackID int64 `json:"track_id" binding:"Required"` // NEW -} -``` - -Update `SubmitScoreForm` → `SubmitScoresForm`: -```go -type SubmitScoresForm struct { - Scores []struct { - CriteriaID int64 `json:"criteria_id"` - Score float64 `json:"score"` - Comment string `json:"comment"` - } `json:"scores" binding:"Required"` -} -``` - -Update `AddJudge` handler to pass `f.TrackID`. -Update `RemoveJudge` to accept `track_id` query param. -Replace `SubmitScore` handler with `SubmitScores`. -Update `GetLeaderboard` to return per-track grouped results. - -Add new handlers: -- `ListCriteria`, `AddCriteriaAPI`, `UpdateCriteriaAPI`, `DeleteCriteriaAPI` -- `GetTrackEffectiveRubric`, `SetTrackCriteriaOverrideAPI` -- `FinalizePreviewAPI`, `FinalizeConfirmAPI` - -- [ ] **Step 2: Update API route registration** - -In `routers/api/v1/api.go`, within the hackathons group (line ~1788): - -```go -m.Get("/criteria", hackforger_api.ListCriteria) -m.Post("/criteria", reqToken(), bind(hackforger_api.AddCriteriaForm{}), hackforger_api.AddCriteriaAPI) -m.Put("/criteria/{cid}", reqToken(), bind(hackforger_api.UpdateCriteriaForm{}), hackforger_api.UpdateCriteriaAPI) -m.Delete("/criteria/{cid}", reqToken(), hackforger_api.DeleteCriteriaAPI) -m.Get("/tracks/{tid}/criteria", hackforger_api.GetTrackEffectiveRubric) -m.Put("/tracks/{tid}/criteria/{cid}", reqToken(), bind(hackforger_api.TrackCriteriaOverrideForm{}), hackforger_api.SetTrackCriteriaOverrideAPI) -m.Get("/finalize-preview", reqToken(), hackforger_api.FinalizePreviewAPI) -m.Post("/finalize-confirm", reqToken(), hackforger_api.FinalizeConfirmAPI) -``` - -Update existing judge/score routes to use new forms. - -- [ ] **Step 3: Verify full backend compilation** - -Run: `TAGS="bindata sqlite sqlite_unlock_notify" make backend` -Expected: Clean compilation. - -- [ ] **Step 4: Commit** - -```bash -git add routers/api/v1/hackforger/hackathon_judge.go routers/api/v1/api.go -git commit -m "feat(judge): update API routes and handlers for multi-criteria judge system" -``` - ---- - -## Chunk 4: Templates + i18n - -### Task 14: Add i18n keys - -**Files:** -- Modify: `options/locale/locale_en-US.ini` -- Modify: `options/locale/locale_zh-CN.ini` - -- [ ] **Step 1: Add English i18n keys** - -Add all ~35 new keys from the spec's i18n section to `options/locale/locale_en-US.ini` under the `[hackforger]` section, near the existing `hackathon.judge.*` and `hackathon.manage.*` keys. - -Also add these additional keys not in the original spec list (needed by error handlers): -``` -hackathon.manage.finalize_success = Hackathon finalized successfully -hackathon.error.user_not_found = User not found: %s -hackathon.error.duplicate_judge = This judge is already assigned to this track. -hackathon.error.internal = An internal error occurred. Please try again. -``` - -- [ ] **Step 2: Add Chinese i18n keys** - -Add corresponding Chinese translations to `options/locale/locale_zh-CN.ini`. - -- [ ] **Step 3: Commit** - -```bash -git add options/locale/locale_en-US.ini options/locale/locale_zh-CN.ini -git commit -m "feat(judge): add i18n keys for multi-criteria judge system (en-US + zh-CN)" -``` - -### Task 15: Rewrite manage.tmpl — criteria + track criteria + finalize sections - -**Files:** -- Modify: `templates/hackforger/hackathon/manage.tmpl` - -- [ ] **Step 1: Add criteria management section** - -After the tracks section, add a new `
` for criteria management: -- Table listing criteria (name, description, maxScore, weight, sortOrder) with edit/delete forms -- Add criterion form (visible only when status is Draft or Open) -- Warning message when no criteria defined: `{{if and (not .Criteria) (le .Hackathon.Status 2)}}
...
{{end}}` - -- [ ] **Step 2: Add track criteria overrides section** - -After criteria section, add per-track criteria configuration: -- Iterate over tracks, for each track show its criteria overrides -- Checkboxes for enabled/disabled, weight input fields -- POST to `/manage/tracks/{tid}/criteria` - -- [ ] **Step 3: Update judge section** - -Replace the raw user_id input (line ~31) with: -- Username text input + track dropdown -- Group judges display by track name - -- [ ] **Step 4: Add finalize preview section** - -When `hackathon.Status == 3` (Judging), replace the old finalize button with: -- "Preview Results" link to `/manage/finalize-preview` -- If `FinalizePreview` data is present, show per-track ranking tables -- "Confirm & Finalize" form with `onclick="return confirm('...')"` confirmation - -- [ ] **Step 5: Commit** - -```bash -git add templates/hackforger/hackathon/manage.tmpl -git commit -m "feat(judge): add criteria, track overrides, and finalize preview to manage template" -``` - -### Task 16: Rewrite judge.tmpl — Vue mount point - -**Files:** -- Modify: `templates/hackforger/hackathon/judge.tmpl` - -- [ ] **Step 1: Replace with Vue mount point** - -Replace entire template content with: - -```html -{{template "base/head" .}} -
-
-

{{ctx.Locale.Tr "hackforger.hackathon.judge"}} — {{.Hackathon.Name}}

-
-
- -
-
-{{template "base/footer" .}} -``` - -- [ ] **Step 2: Commit** - -```bash -git add templates/hackforger/hackathon/judge.tmpl -git commit -m "feat(judge): rewrite judge.tmpl with Vue mount point" -``` - -### Task 17: Enhance leaderboard.tmpl — per-track tabs - -**Files:** -- Modify: `templates/hackforger/hackathon/leaderboard.tmpl` - -- [ ] **Step 1: Add per-track tabs and criteria breakdown** - -Replace existing leaderboard content with: -- Tab bar iterating over tracks -- Per-track table with Rank, Team/Project, Weighted Total columns -- Criteria breakdown columns (only when `.ShowBreakdown` is true) -- Top 3 highlighting with medal CSS classes - -- [ ] **Step 2: Update view.tmpl judge button** - -In `templates/hackforger/hackathon/view.tmpl`, the judge button already uses `.IsJudge` (line ~22). The handler now sets this from `IsJudgeForAnyTrack` — no template change needed. - -- [ ] **Step 3: Commit** - -```bash -git add templates/hackforger/hackathon/leaderboard.tmpl -git commit -m "feat(judge): per-track leaderboard with criteria breakdown" -``` - ---- - -## Chunk 5: Vue Component + Frontend - -### Task 18: Create JudgeScoreCard.vue - -**Files:** -- Create: `web_src/js/components/hackforger/JudgeScoreCard.vue` - -- [ ] **Step 1: Create the Vue component** - -Create `web_src/js/components/hackforger/JudgeScoreCard.vue` using Options API: - -```vue - - - -``` - -- [ ] **Step 2: Commit** - -```bash -git add web_src/js/components/hackforger/JudgeScoreCard.vue -git commit -m "feat(judge): create JudgeScoreCard.vue interactive scoring component" -``` - -### Task 19: Register Vue component in init.js - -**Files:** -- Modify: `web_src/js/features/hackforger/init.js` - -- [ ] **Step 1: Add JudgeScoreCard lazy-load mount** - -Add after the BountyPanel block: - -```js -// JudgeScoreCard -const judgeEl = document.getElementById('hackforger-judge-scorecard'); -if (judgeEl) { - (async () => { - const {default: JudgeScoreCard} = await import( - /* webpackChunkName: "hackforger-judge" */ - '../../components/hackforger/JudgeScoreCard.vue' - ); - const {createApp} = await import('vue'); - createApp(JudgeScoreCard, { - hackathonSlug: judgeEl.dataset.hackathonSlug, - tracks: JSON.parse(judgeEl.dataset.tracks), - submissions: JSON.parse(judgeEl.dataset.submissions), - rubrics: JSON.parse(judgeEl.dataset.rubrics), - }).mount(judgeEl); - })(); -} -``` - -- [ ] **Step 2: Build frontend** - -Run: `make frontend` -Expected: Clean build with new `hackforger-judge` chunk. - -- [ ] **Step 3: Commit** - -```bash -git add web_src/js/features/hackforger/init.js -git commit -m "feat(judge): register JudgeScoreCard in init.js with lazy-load" -``` - ---- - -## Chunk 6: Integration + Build Verification - -### Task 20: Full build and smoke test - -- [ ] **Step 1: Build backend with embedded assets** - -Run: `TAGS="bindata sqlite sqlite_unlock_notify" make build` -Expected: Clean compilation. - -- [ ] **Step 2: Build frontend** - -Run: `make frontend` -Expected: Clean build. - -- [ ] **Step 3: Start server and verify pages load** - -```bash -mkdir -p custom/conf -cp /Users/h2oslabs/Workspace/hackforger/custom/conf/app.ini custom/conf/app.ini -rm -f /Users/h2oslabs/Workspace/hackforger/data/queues/common/LOCK -kill $(lsof -t -i :3000) 2>/dev/null; sleep 2 -./gitea web -``` - -Verify: -- `/hackathons/new` loads -- `/explore/hackathons` loads -- No template panics in server logs - -- [ ] **Step 4: Commit any fixes** - -If any compilation or template issues were found, fix and commit. - -### Task 21: Final commit — tag completion - -- [ ] **Step 1: Verify all changes are committed** - -Run: `git status` -Expected: Clean working tree. - -- [ ] **Step 2: Run model tests** - -Run: `go test ./models/hackforger/... -v -count=1` -Note: Tests may need test fixture data for new tables. If no test fixtures exist yet, verify compilation only. - -- [ ] **Step 3: Run service tests** - -Run: `go test ./services/hackforger/... -v -count=1` - -- [ ] **Step 4: Final commit if needed** - -```bash -git commit -m "feat(judge): Phase 2 judge system — build verified" -``` - ---- - -## Post-Implementation - -After all tasks are complete: - -1. **E2E testing**: Follow `docs/tests/e2e/phase2-judge-e2e-prompt.md` (15 test cases) -2. **Feed integration**: After feed refactor merges, update 2 `PublishHackforgerAction` call sites in `SubmitScores` and `ConfirmFinalize` to use new `HackforgerActionOpts` format -3. **PR creation**: Create PR to `v0.1-dev/hackforger` branch diff --git a/docs/superpowers/plans/2026-03-30-phase2-reputation-feed-profile.md b/docs/superpowers/plans/2026-03-30-phase2-reputation-feed-profile.md deleted file mode 100644 index f0b937c6ee..0000000000 --- a/docs/superpowers/plans/2026-03-30-phase2-reputation-feed-profile.md +++ /dev/null @@ -1,1667 +0,0 @@ -# Phase 2: Reputation + Feed + Profile — Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Separate HackForger feed into its own table, add reputation system with admin-configurable weights/tiers, and integrate both into Dashboard and Profile pages. - -**Architecture:** New `hackforger_action` table replaces writing to Forgejo's `action` table. Reputation scores computed via cron from DB metrics + admin-configurable weights stored in `hackforger_setting` table. Dashboard and Profile get "Community" tabs; Profile gets reputation sidebar card and "Reputation" tab. - -**Tech Stack:** Go (XORM ORM), Go HTML templates (SSR), Forgejo's `chi` router, i18n via locale INI files. - -**Spec:** `docs/superpowers/specs/2026-03-30-phase2-reputation-feed-profile-design.md` - ---- - -## Chunk 1: Data Layer - -### Task 1: HackforgerAction Model - -**Files:** -- Create: `models/hackforger/hackforger_action.go` -- Create: `models/hackforger/hackforger_action_test.go` - -- [ ] **Step 1: Create the model file with struct + init** - -```go -// models/hackforger/hackforger_action.go -package hackforger - -import ( - "context" - - activities_model "forgejo.org/models/activities" - "forgejo.org/models/db" - user_model "forgejo.org/models/user" - "forgejo.org/modules/timeutil" - - "xorm.io/builder" -) - -// HackforgerAction stores HackForger feed events in a separate table -// from Forgejo's repo-centric action table. -type HackforgerAction struct { - ID int64 `xorm:"pk autoincr"` - UserID int64 `xorm:"NOT NULL INDEX(idx_hfa_user_created)"` - ActUserID int64 `xorm:"NOT NULL INDEX(idx_hfa_actuser_created)"` - OpType activities_model.ActionType `xorm:"NOT NULL"` - EntityType string `xorm:"VARCHAR(20) NOT NULL INDEX(idx_hfa_entity)"` - EntityID int64 `xorm:"NOT NULL DEFAULT 0"` - EntityName string `xorm:"VARCHAR(255)"` - EntitySlug string `xorm:"VARCHAR(255)"` - OrgID int64 `xorm:"DEFAULT 0"` - RepoID int64 `xorm:"DEFAULT 0"` - Content string `xorm:"TEXT"` - CreatedUnix timeutil.TimeStamp `xorm:"INDEX(idx_hfa_user_created) INDEX(idx_hfa_actuser_created) INDEX(idx_hfa_entity) created"` - - ActUser *user_model.User `xorm:"-"` -} - -func init() { - db.RegisterModel(new(HackforgerAction)) -} - -// GetHackforgerFeedsOptions configures feed queries. -type GetHackforgerFeedsOptions struct { - db.ListOptions - UserID int64 - ActUserID int64 - EntityType string - EntityID int64 - IncludeGlobal bool -} - -// GetHackforgerFeeds returns feed events matching the given options. -func GetHackforgerFeeds(ctx context.Context, opts GetHackforgerFeedsOptions) ([]*HackforgerAction, int64, error) { - cond := builder.NewCond() - - if opts.UserID > 0 { - if opts.IncludeGlobal { - cond = cond.And(builder.Or( - builder.Eq{"user_id": opts.UserID}, - builder.Eq{"user_id": 0}, - )) - } else { - cond = cond.And(builder.Eq{"user_id": opts.UserID}) - } - } else if opts.IncludeGlobal { - cond = cond.And(builder.Eq{"user_id": 0}) - } - - if opts.ActUserID > 0 { - cond = cond.And(builder.Eq{"act_user_id": opts.ActUserID}) - } - - if opts.EntityType != "" { - cond = cond.And(builder.Eq{"entity_type": opts.EntityType}) - } - - if opts.EntityID > 0 { - cond = cond.And(builder.Eq{"entity_id": opts.EntityID}) - } - - opts.SetDefaultValues() - sess := db.GetEngine(ctx).Where(cond) - sess = db.SetSessionPagination(sess, &opts.ListOptions) - - actions := make([]*HackforgerAction, 0, opts.PageSize) - count, err := sess.Desc("created_unix").FindAndCount(&actions) - return actions, count, err -} - -// GetEntityTimeline returns all events for a specific entity, deduplicated. -func GetEntityTimeline(ctx context.Context, entityType string, entityID int64, opts db.ListOptions) ([]*HackforgerAction, int64, error) { - cond := builder.Eq{"entity_type": entityType, "entity_id": entityID} - - opts.SetDefaultValues() - sess := db.GetEngine(ctx).Where(cond). - GroupBy("act_user_id, op_type, entity_type, entity_id, created_unix") - sess = db.SetSessionPagination(sess, &opts) - - actions := make([]*HackforgerAction, 0, opts.PageSize) - count, err := sess.Desc("created_unix").FindAndCount(&actions) - return actions, count, err -} - -// LoadActUsers bulk-loads ActUser for a slice of HackforgerActions. -func LoadActUsers(ctx context.Context, actions []*HackforgerAction) error { - if len(actions) == 0 { - return nil - } - - userIDs := make([]int64, 0, len(actions)) - seen := make(map[int64]bool) - for _, a := range actions { - if !seen[a.ActUserID] { - userIDs = append(userIDs, a.ActUserID) - seen[a.ActUserID] = true - } - } - - userMap := make(map[int64]*user_model.User) - if len(userIDs) > 0 { - var users []*user_model.User - if err := db.GetEngine(ctx).In("id", userIDs).Find(&users); err != nil { - return err - } - for _, u := range users { - userMap[u.ID] = u - } - } - - for _, a := range actions { - a.ActUser = userMap[a.ActUserID] - } - return nil -} -``` - -- [ ] **Step 2: Write tests** - -```go -// models/hackforger/hackforger_action_test.go -package hackforger_test - -import ( - "testing" - - "forgejo.org/models/db" - hackforger_model "forgejo.org/models/hackforger" - "forgejo.org/models/unittest" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGetHackforgerFeeds_Global(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // Insert a global event (UserID=0) - action := &hackforger_model.HackforgerAction{ - UserID: 0, - ActUserID: 2, - OpType: 30, // hackathon_created - EntityType: "hackathon", - EntityID: 1, - EntityName: "Test Hackathon", - } - _, err := db.GetEngine(db.DefaultContext).Insert(action) - require.NoError(t, err) - - // Query with IncludeGlobal should find it - feeds, count, err := hackforger_model.GetHackforgerFeeds(db.DefaultContext, hackforger_model.GetHackforgerFeedsOptions{ - UserID: 99, // any user - IncludeGlobal: true, - ListOptions: db.ListOptions{Page: 1, PageSize: 10}, - }) - require.NoError(t, err) - assert.True(t, count >= 1) - assert.True(t, len(feeds) >= 1) -} - -func TestGetHackforgerFeeds_ByActUser(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - action := &hackforger_model.HackforgerAction{ - UserID: 5, - ActUserID: 2, - OpType: 34, // bounty_created - EntityType: "bounty", - EntityID: 10, - EntityName: "Fix login bug", - } - _, err := db.GetEngine(db.DefaultContext).Insert(action) - require.NoError(t, err) - - feeds, count, err := hackforger_model.GetHackforgerFeeds(db.DefaultContext, hackforger_model.GetHackforgerFeedsOptions{ - ActUserID: 2, - ListOptions: db.ListOptions{Page: 1, PageSize: 10}, - }) - require.NoError(t, err) - assert.True(t, count >= 1) - found := false - for _, f := range feeds { - if f.EntityName == "Fix login bug" { - found = true - break - } - } - assert.True(t, found) -} - -func TestLoadActUsers(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - actions := []*hackforger_model.HackforgerAction{ - {ActUserID: 1}, - {ActUserID: 2}, - {ActUserID: 1}, // duplicate - } - err := hackforger_model.LoadActUsers(db.DefaultContext, actions) - require.NoError(t, err) - assert.NotNil(t, actions[0].ActUser) - assert.NotNil(t, actions[1].ActUser) - assert.Equal(t, actions[0].ActUser, actions[2].ActUser) -} -``` - -- [ ] **Step 3: Run tests to verify they pass** - -Run: `go test ./models/hackforger/... -run TestGetHackforgerFeeds -v && go test ./models/hackforger/... -run TestLoadActUsers -v` - -- [ ] **Step 4: Commit** - -```bash -git add models/hackforger/hackforger_action.go models/hackforger/hackforger_action_test.go -git commit -m "feat(feed): add HackforgerAction model with query functions and tests" -``` - ---- - -### Task 2: HackforgerSetting Model - -**Files:** -- Create: `models/hackforger/setting.go` -- Create: `models/hackforger/setting_test.go` - -- [ ] **Step 1: Create the model file** - -```go -// models/hackforger/setting.go -package hackforger - -import ( - "context" - - "forgejo.org/models/db" -) - -// HackforgerSetting stores HackForger-specific configuration in the database. -type HackforgerSetting struct { - ID int64 `xorm:"pk autoincr"` - Key string `xorm:"VARCHAR(100) UNIQUE NOT NULL"` - Value string `xorm:"TEXT NOT NULL"` -} - -func init() { - db.RegisterModel(new(HackforgerSetting)) -} - -// GetSetting returns the value for a key, or empty string + error if not found. -func GetSetting(ctx context.Context, key string) (string, error) { - s := new(HackforgerSetting) - has, err := db.GetEngine(ctx).Where("`key` = ?", key).Get(s) - if err != nil { - return "", err - } - if !has { - return "", nil - } - return s.Value, nil -} - -// GetSettingWithDefault returns the value for a key, or defaultValue if not found. -func GetSettingWithDefault(ctx context.Context, key, defaultValue string) string { - val, err := GetSetting(ctx, key) - if err != nil || val == "" { - return defaultValue - } - return val -} - -// SetSetting creates or updates a setting. -func SetSetting(ctx context.Context, key, value string) error { - s := new(HackforgerSetting) - has, err := db.GetEngine(ctx).Where("`key` = ?", key).Get(s) - if err != nil { - return err - } - if has { - s.Value = value - _, err = db.GetEngine(ctx).ID(s.ID).Cols("value").Update(s) - return err - } - s = &HackforgerSetting{Key: key, Value: value} - _, err = db.GetEngine(ctx).Insert(s) - return err -} -``` - -- [ ] **Step 2: Write tests** - -```go -// models/hackforger/setting_test.go -package hackforger_test - -import ( - "testing" - - "forgejo.org/models/db" - hackforger_model "forgejo.org/models/hackforger" - "forgejo.org/models/unittest" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestSetting_GetSetDefault(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // GetSettingWithDefault returns default when key missing - val := hackforger_model.GetSettingWithDefault(db.DefaultContext, "test.key", "fallback") - assert.Equal(t, "fallback", val) - - // SetSetting creates - require.NoError(t, hackforger_model.SetSetting(db.DefaultContext, "test.key", "hello")) - val, err := hackforger_model.GetSetting(db.DefaultContext, "test.key") - require.NoError(t, err) - assert.Equal(t, "hello", val) - - // SetSetting updates - require.NoError(t, hackforger_model.SetSetting(db.DefaultContext, "test.key", "world")) - val, err = hackforger_model.GetSetting(db.DefaultContext, "test.key") - require.NoError(t, err) - assert.Equal(t, "world", val) -} -``` - -- [ ] **Step 3: Run tests** - -Run: `go test ./models/hackforger/... -run TestSetting -v` - -- [ ] **Step 4: Commit** - -```bash -git add models/hackforger/setting.go models/hackforger/setting_test.go -git commit -m "feat(settings): add HackforgerSetting model for admin-configurable settings" -``` - ---- - -### Task 3: Reputation Model Update - -**Files:** -- Modify: `models/hackforger/reputation.go` (add `Tier` field) - -- [ ] **Step 1: Add Tier field to Reputation struct** - -In `models/hackforger/reputation.go`, add after `TotalCreditsEarned`: - -```go -Tier string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'Bronze'"` -``` - -- [ ] **Step 2: Verify compilation** - -Run: `go build ./models/hackforger/...` - -- [ ] **Step 3: Commit** - -```bash -git add models/hackforger/reputation.go -git commit -m "feat(reputation): add Tier field to Reputation model" -``` - ---- - -### Task 4: Migration - -**Files:** -- Create: `models/forgejo_migrations/v14g_hackforger-phase2-tables.go` - -- [ ] **Step 1: Create migration file** - -```go -// models/forgejo_migrations/v14g_hackforger-phase2-tables.go -package forgejo_migrations - -import ( - "forgejo.org/modules/timeutil" - - "xorm.io/xorm" -) - -func init() { - registerMigration(&Migration{ - Description: "add HackForger phase 2 tables (hackforger_action, hackforger_setting) and reputation tier", - Upgrade: addHackforgerPhase2Tables, - }) -} - -func addHackforgerPhase2Tables(x *xorm.Engine) error { - // Must explicitly Sync new tables — db.RegisterModel only runs at - // engine init, not during migration execution. - type HackforgerAction struct { - ID int64 `xorm:"pk autoincr"` - UserID int64 `xorm:"NOT NULL INDEX(idx_hfa_user_created)"` - ActUserID int64 `xorm:"NOT NULL INDEX(idx_hfa_actuser_created)"` - OpType int `xorm:"NOT NULL"` - EntityType string `xorm:"VARCHAR(20) NOT NULL INDEX(idx_hfa_entity)"` - EntityID int64 `xorm:"NOT NULL DEFAULT 0"` - EntityName string `xorm:"VARCHAR(255)"` - EntitySlug string `xorm:"VARCHAR(255)"` - OrgID int64 `xorm:"DEFAULT 0"` - RepoID int64 `xorm:"DEFAULT 0"` - Content string `xorm:"TEXT"` - CreatedUnix timeutil.TimeStamp `xorm:"INDEX(idx_hfa_user_created) INDEX(idx_hfa_actuser_created) INDEX(idx_hfa_entity) created"` - } - if err := x.Sync(new(HackforgerAction)); err != nil { - return err - } - - type HackforgerSetting struct { - ID int64 `xorm:"pk autoincr"` - Key string `xorm:"VARCHAR(100) UNIQUE NOT NULL"` - Value string `xorm:"TEXT NOT NULL"` - } - if err := x.Sync(new(HackforgerSetting)); err != nil { - return err - } - - // Add tier column to existing reputation table - type Reputation struct { - Tier string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'Bronze'"` - } - if err := x.Sync(new(Reputation)); err != nil { - return err - } - - // Seed default settings - defaults := []HackforgerSetting{ - {Key: "reputation.weights", Value: `{"stars":1,"bounties_completed":5,"hackathon_wins":10,"grants_received":3,"credits_earned":0.1}`}, - {Key: "reputation.tiers", Value: `[{"name":"Bronze","min":0},{"name":"Silver","min":50},{"name":"Gold","min":200},{"name":"Diamond","min":500}]`}, - } - for _, s := range defaults { - has, err := x.Where("`key` = ?", s.Key).Exist(new(HackforgerSetting)) - if err != nil { - return err - } - if !has { - if _, err := x.Insert(&s); err != nil { - return err - } - } - } - return nil -} -``` - -- [ ] **Step 2: Verify compilation** - -Run: `go build ./models/forgejo_migrations/...` - -- [ ] **Step 3: Commit** - -```bash -git add models/forgejo_migrations/v14g_hackforger-phase2-tables.go -git commit -m "feat(migration): add Phase 2 tables and seed default reputation settings" -``` - ---- - -### Task 5: i18n Keys (All) - -**Files:** -- Modify: `options/locale/locale_en-US.ini` -- Modify: `options/locale/locale_zh-CN.ini` - -- [ ] **Step 1: Add all i18n keys to both locale files** - -Add under the existing `[hackforger]` section in both files. - -**English (`locale_en-US.ini`):** -```ini -; Feed tabs -feed.code = Code Activity -feed.community = Community -feed.community_empty = No community activity yet. Explore hackathons, bounties, and grants to get started! - -; Profile tabs -profile.community = Community -profile.reputation = Reputation - -; Feed event messages -feed.hackathon_created = created hackathon %[1]s -feed.hackathon_registered = registered for %[1]s -feed.hackathon_submitted = submitted to %[1]s -feed.hackathon_scored = scored a submission in %[1]s -feed.hackathon_phase_changed = changed phase of %[1]s -feed.hackathon_finalized = finalized %[1]s -feed.bounty_created = created a bounty in %[1]s -feed.bounty_claimed = claimed a bounty in %[1]s -feed.bounty_delivered = delivered a bounty in %[1]s -feed.bounty_completed = completed a bounty in %[1]s -feed.bounty_paid = paid a bounty in %[1]s -feed.bounty_winners_selected = selected bounty winners in %[1]s -feed.bounty_expired = bounty expired in %[1]s -feed.bounty_cancelled = cancelled a bounty in %[1]s -feed.grant_round_created = created grant round %[1]s -feed.grant_project_submitted = submitted a project to %[1]s -feed.grant_awarded = received a grant from %[1]s -feed.grant_round_opened = opened grant round %[1]s -feed.grant_round_closed = closed grant round %[1]s -feed.grant_round_finalized = finalized grant round %[1]s -feed.grant_round_cancelled = cancelled grant round %[1]s -feed.credits_redeemed = redeemed credits - -; Reputation -reputation.score = Reputation Score -reputation.tier = Tier -reputation.bounties_completed = Bounties Completed -reputation.hackathon_wins = Hackathon Wins -reputation.grants_received = Grants Received -reputation.total_stars = Total Stars -reputation.credits_earned = Credits Earned -reputation.leaderboard = Reputation Leaderboard -reputation.rank = Rank -reputation.view_leaderboard = View Leaderboard - -; Admin reputation -admin.reputation = Reputation Settings -admin.reputation.weights = Score Weights -admin.reputation.tiers = Tier Thresholds -admin.reputation.recalc = Recalculate All -admin.reputation.recalc_success = Reputation recalculation triggered successfully -admin.reputation.save_success = Reputation settings saved -``` - -**Chinese (`locale_zh-CN.ini`):** Same keys with Chinese translations: -```ini -feed.code = 代码动态 -feed.community = 社区动态 -feed.community_empty = 暂无社区动态。去探索 Hackathon、悬赏和资助吧! - -profile.community = 社区 -profile.reputation = 声誉 - -feed.hackathon_created = 创建了 Hackathon %[1]s -feed.hackathon_registered = 报名了 %[1]s -feed.hackathon_submitted = 提交了作品到 %[1]s -feed.hackathon_scored = 评审了 %[1]s 的提交 -feed.hackathon_phase_changed = 更改了 %[1]s 的阶段 -feed.hackathon_finalized = 公布了 %[1]s 的结果 -feed.bounty_created = 在 %[1]s 发布了悬赏 -feed.bounty_claimed = 认领了 %[1]s 的悬赏 -feed.bounty_delivered = 交付了 %[1]s 的悬赏 -feed.bounty_completed = 完成了 %[1]s 的悬赏 -feed.bounty_paid = 支付了 %[1]s 的悬赏 -feed.bounty_winners_selected = 选出了 %[1]s 的获奖者 -feed.bounty_expired = %[1]s 的悬赏已过期 -feed.bounty_cancelled = 取消了 %[1]s 的悬赏 -feed.grant_round_created = 创建了资助轮次 %[1]s -feed.grant_project_submitted = 提交了项目到 %[1]s -feed.grant_awarded = 获得了 %[1]s 的资助 -feed.grant_round_opened = 开放了资助轮次 %[1]s -feed.grant_round_closed = 关闭了资助轮次 %[1]s -feed.grant_round_finalized = 完结了资助轮次 %[1]s -feed.grant_round_cancelled = 取消了资助轮次 %[1]s -feed.credits_redeemed = 兑换了积分 - -reputation.score = 声誉分 -reputation.tier = 等级 -reputation.bounties_completed = 完成的悬赏 -reputation.hackathon_wins = Hackathon 获奖 -reputation.grants_received = 获得的资助 -reputation.total_stars = 收获的 Star -reputation.credits_earned = 累计获得积分 -reputation.leaderboard = 声誉排行榜 -reputation.rank = 排名 -reputation.view_leaderboard = 查看排行榜 - -admin.reputation = 声誉设置 -admin.reputation.weights = 评分权重 -admin.reputation.tiers = 等级阈值 -admin.reputation.recalc = 重新计算 -admin.reputation.recalc_success = 已触发声誉重新计算 -admin.reputation.save_success = 声誉设置已保存 -``` - -- [ ] **Step 2: Verify no syntax errors in locale files** - -Run: `go build ./...` (locale files are embedded at build time with `bindata` tag; compilation verifies syntax) - -- [ ] **Step 3: Commit** - -```bash -git add options/locale/locale_en-US.ini options/locale/locale_zh-CN.ini -git commit -m "i18n: add Phase 2 locale keys for feed tabs, reputation, and admin" -``` - ---- - -## Chunk 2: Feed System Refactor - -### Task 6: Notifier Refactor - -**Files:** -- Modify: `services/hackforger/notifier.go` - -- [ ] **Step 1: Fix AudienceType to use bit flags** - -Replace the `AudienceType` constants (lines 33-40): - -```go -// Before: -// AudienceGlobal AudienceType = iota -// AudienceFollowers -// AudienceOrgMembers -// AudienceRepoWatchers - -// After: -const ( - AudienceGlobal AudienceType = 1 << iota // 1 - AudienceFollowers // 2 - AudienceOrgMembers // 4 - AudienceRepoWatchers // 8 -) -``` - -- [ ] **Step 2: Update HackforgerActionOpts with entity fields** - -Replace the struct definition: - -```go -type HackforgerActionOpts struct { - ActUserID int64 - OpType activities_model.ActionType - EntityType string - EntityID int64 - EntityName string - EntitySlug string - RepoID int64 - Content any - AudienceType AudienceType - OrgID int64 -} -``` - -- [ ] **Step 3: Rewrite PublishHackforgerAction to write to hackforger_action table** - -Replace the entire function body. Key changes: -- Write to `hackforger_model.HackforgerAction` instead of `activities_model.Action` -- Use `if opts.AudienceType&AudienceX != 0` flag checks instead of `switch` -- Always insert actor record; additionally insert per-audience records - -```go -func PublishHackforgerAction(ctx context.Context, opts *HackforgerActionOpts) error { - contentStr := "" - if opts.Content != nil { - contentBytes, err := json.Marshal(opts.Content) - if err != nil { - return err - } - contentStr = string(contentBytes) - } - now := timeutil.TimeStampNow() - - // Helper to insert a single record - insert := func(userID int64) { - a := &hackforger_model.HackforgerAction{ - UserID: userID, - ActUserID: opts.ActUserID, - OpType: opts.OpType, - EntityType: opts.EntityType, - EntityID: opts.EntityID, - EntityName: opts.EntityName, - EntitySlug: opts.EntitySlug, - OrgID: opts.OrgID, - RepoID: opts.RepoID, - Content: contentStr, - CreatedUnix: now, - } - if _, err := db.GetEngine(ctx).Insert(a); err != nil { - log.Error("PublishHackforgerAction (user_id=%d): %v", userID, err) - } - } - - // Actor's own record - insert(opts.ActUserID) - - // Global audience - if opts.AudienceType&AudienceGlobal != 0 { - insert(0) - } - - // Followers audience - if opts.AudienceType&AudienceFollowers != 0 { - var followerIDs []int64 - if err := db.GetEngine(ctx).Table("follow"). - Where("follow_id = ?", opts.ActUserID). - Cols("user_id").Find(&followerIDs); err != nil { - log.Error("PublishHackforgerAction (followers query): %v", err) - } else { - for _, uid := range followerIDs { - if uid != opts.ActUserID { - insert(uid) - } - } - } - } - - // Org members audience - if opts.AudienceType&AudienceOrgMembers != 0 && opts.OrgID > 0 { - var memberIDs []int64 - if err := db.GetEngine(ctx).Table("org_user"). - Where("org_id = ?", opts.OrgID). - Cols("uid").Find(&memberIDs); err != nil { - log.Error("PublishHackforgerAction (org members query): %v", err) - } else { - for _, uid := range memberIDs { - if uid != opts.ActUserID { - insert(uid) - } - } - } - } - - // Repo watchers audience - if opts.AudienceType&AudienceRepoWatchers != 0 && opts.RepoID > 0 { - var watcherIDs []int64 - if err := db.GetEngine(ctx).Table("watch"). - Where("repo_id = ? AND mode != 2", opts.RepoID). - Cols("user_id").Find(&watcherIDs); err != nil { - log.Error("PublishHackforgerAction (watchers query): %v", err) - } else { - for _, uid := range watcherIDs { - if uid != opts.ActUserID { - insert(uid) - } - } - } - } - - return nil -} -``` - -- [ ] **Step 4: Remove old imports for `activities_model.Action` that are no longer needed** - -Update import block — keep `activities_model` (still used for ActionType), remove unused imports. Remove the `MergePullRequest` method's unused variable placeholders. - -- [ ] **Step 5: Verify compilation** - -Run: `go build ./services/hackforger/...` - -Expected: Compilation errors in callers (bounty.go, hackathon.go, grants.go, credits.go) because `HackforgerActionOpts` now requires new fields. This is expected — Task 7 fixes callers. - -- [ ] **Step 6: Commit** - -```bash -git add services/hackforger/notifier.go -git commit -m "refactor(feed): rewrite notifier to write hackforger_action table with bit-flag audiences" -``` - ---- - -### Task 7: Update All Existing Callers - -**Files:** -- Modify: `services/hackforger/bounty.go` (all `PublishHackforgerAction` calls) -- Modify: `services/hackforger/hackathon.go` (all calls) -- Modify: `services/hackforger/grants.go` (all calls, including `publishGrantEvent` helper) -- Modify: `services/hackforger/credits.go` (all calls) - -- [ ] **Step 1: Update each caller to pass EntityType/EntityID/EntityName/EntitySlug** - -The pattern for each caller: -- Extract entity fields from the existing `Content` struct (they're already there) -- Pass them as top-level opts fields - -**Example transformation for hackathon.go `publishPhaseChange`:** - -Before: -```go -_ = PublishHackforgerAction(ctx, &HackforgerActionOpts{ - ActUserID: doerID, - OpType: hackforger_model.ActionHackathonPhaseChanged, - AudienceType: AudienceOrgMembers, - OrgID: h.OrgID, - Content: hackforger_model.HackforgerPhaseContent{...}, -}) -``` - -After: -```go -_ = PublishHackforgerAction(ctx, &HackforgerActionOpts{ - ActUserID: doerID, - OpType: hackforger_model.ActionHackathonPhaseChanged, - EntityType: "hackathon", - EntityID: h.ID, - EntityName: h.Name, - EntitySlug: h.Slug, - AudienceType: AudienceOrgMembers, - OrgID: h.OrgID, - Content: hackforger_model.HackforgerPhaseContent{...}, -}) -``` - -Apply this pattern to ALL callers. Search for `PublishHackforgerAction` in each file and add the entity fields. - -**IMPORTANT — Audit `AudienceType: 0` usages:** After the bit-flag change, `AudienceType: 0` means "no audience at all" (not even actor record from the global check). Search for any caller that uses `AudienceType: 0` or omits `AudienceType` (Go zero value). If the intent was "actor only, no audience distribution", change to explicitly using `AudienceType: AudienceFollowers` (actor's followers) or remove the field and ensure the actor record is still created in `PublishHackforgerAction`. - -**For grants.go `publishGrantEvent` helper:** Add entity fields as parameters or extract from `round` parameter: -```go -func publishGrantEvent(ctx context.Context, actUserID int64, opType activities_model.ActionType, round *hackforger_model.GrantRound, audience AudienceType) error { - // ... existing content creation ... - return PublishHackforgerAction(ctx, &HackforgerActionOpts{ - ActUserID: actUserID, - OpType: opType, - EntityType: "grant", - EntityID: round.ID, - EntityName: round.Name, - EntitySlug: round.Slug, - Content: content, - AudienceType: audience, - OrgID: round.OrgID, - }) -} -``` - -- [ ] **Step 2: Verify full compilation** - -Run: `go build ./services/hackforger/...` -Expected: PASS — all callers now supply the required fields. - -- [ ] **Step 3: Run existing service tests to verify no regressions** - -Run: `go test ./services/hackforger/... -v` - -- [ ] **Step 4: Commit** - -```bash -git add services/hackforger/bounty.go services/hackforger/hackathon.go services/hackforger/grants.go services/hackforger/credits.go -git commit -m "refactor(feed): update all notifier callers with entity fields for hackforger_action" -``` - ---- - -### Task 8: Feed API Update - -**Files:** -- Modify: `routers/api/v1/hackforger/feed.go` - -- [ ] **Step 1: Rewrite GetFeed to query hackforger_action** - -Replace the entire `GetFeed` function body to use `hackforger_model.GetHackforgerFeeds` instead of directly querying the `action` table. Add `entity_type`, `entity_id`, `user_id` query params. - -Key changes: -- Remove direct `action` table query -- Use `GetHackforgerFeeds` / `GetEntityTimeline` -- Keep the same `feedItem` JSON response format -- Use `LoadActUsers` for user data - -- [ ] **Step 2: Verify compilation** - -Run: `go build ./routers/api/v1/hackforger/...` - -- [ ] **Step 3: Commit** - -```bash -git add routers/api/v1/hackforger/feed.go -git commit -m "refactor(api): update Feed API to query hackforger_action table" -``` - ---- - -### Task 9: Template Cleanup — Remove HackForger branches from feeds.tmpl - -**Files:** -- Modify: `templates/user/dashboard/feeds.tmpl` - -- [ ] **Step 1: Remove all 22 HackForger `.InActions` branches** - -Remove the entire HackForger block — from `{{else if .GetOpType.InActions "hackforger_bounty_created"}}` through the last `{{else if .GetOpType.InActions "hackforger_credits_redeemed"}}` and its closing content. Search for the marker `hackforger_bounty_created` to find the start, and remove all consecutive `hackforger_*` branches. These events will now be rendered by the Community tab's `community_feeds.tmpl`. - -Keep all Forgejo-native action rendering (commits, PRs, issues, etc.) intact. - -- [ ] **Step 2: Verify template renders (build with bindata tag)** - -Run: `TAGS="bindata sqlite sqlite_unlock_notify" make backend` - -- [ ] **Step 3: Commit** - -```bash -git add templates/user/dashboard/feeds.tmpl -git commit -m "cleanup(feed): remove HackForger event branches from Forgejo feeds template" -``` - ---- - -## Chunk 3: Reputation System - -### Task 10: Reputation Service - -**Files:** -- Create: `services/hackforger/reputation.go` -- Create: `services/hackforger/reputation_test.go` - -- [ ] **Step 1: Create reputation service** - -```go -// services/hackforger/reputation.go -package hackforger - -import ( - "context" - "math" - - "forgejo.org/models/db" - hackforger_model "forgejo.org/models/hackforger" - "forgejo.org/modules/json" - "forgejo.org/modules/log" -) - -// Default weights and tiers (used when settings table has no values). -// Exported via DefaultWeightsJSON() / DefaultTiersJSON() for admin handler. -const ( - defaultWeightsJSON = `{"stars":1,"bounties_completed":5,"hackathon_wins":10,"grants_received":3,"credits_earned":0.1}` - defaultTiersJSON = `[{"name":"Bronze","min":0},{"name":"Silver","min":50},{"name":"Gold","min":200},{"name":"Diamond","min":500}]` -) - -func DefaultWeightsJSON() string { return defaultWeightsJSON } -func DefaultTiersJSON() string { return defaultTiersJSON } - -type ReputationWeights struct { - Stars float64 `json:"stars"` - BountiesCompleted float64 `json:"bounties_completed"` - HackathonWins float64 `json:"hackathon_wins"` - GrantsReceived float64 `json:"grants_received"` - CreditsEarned float64 `json:"credits_earned"` -} - -type ReputationTier struct { - Name string `json:"name"` - Min float64 `json:"min"` -} - -func GetReputationWeights(ctx context.Context) (*ReputationWeights, error) { - raw := hackforger_model.GetSettingWithDefault(ctx, "reputation.weights", defaultWeightsJSON) - var w ReputationWeights - if err := json.Unmarshal([]byte(raw), &w); err != nil { - return nil, err - } - return &w, nil -} - -func GetReputationTiers(ctx context.Context) ([]ReputationTier, error) { - raw := hackforger_model.GetSettingWithDefault(ctx, "reputation.tiers", defaultTiersJSON) - var tiers []ReputationTier - if err := json.Unmarshal([]byte(raw), &tiers); err != nil { - return nil, err - } - return tiers, nil -} - -func deriveTier(score int64, tiers []ReputationTier) string { - tier := "Bronze" - for _, t := range tiers { - if float64(score) >= t.Min { - tier = t.Name - } - } - return tier -} - -// RecalculateReputation recomputes a single user's reputation. -func RecalculateReputation(ctx context.Context, userID int64) error { - rep, err := hackforger_model.GetOrCreateReputation(ctx, userID) - if err != nil { - return err - } - - e := db.GetEngine(ctx) - - // Count metrics (use model constants, not magic integers) - bountiesCompleted, _ := e.Table("bounty"). - Where("claimer_id = ? AND status IN (?, ?)", userID, - hackforger_model.BountyStatusCompleted, hackforger_model.BountyStatusPaid).Count() - hackathonWins, _ := e.Table("hackathon_submission"). - Where("user_id = ? AND rank = 1", userID).Count() - grantsReceived, _ := e.Table("grant_project"). - Where("user_id = ? AND status IN (?, ?)", userID, - hackforger_model.GrantProjectStatusApproved, hackforger_model.GrantProjectStatusFunded).Count() - - var totalStars int64 - _, _ = e.SQL("SELECT COALESCE(SUM(num_stars), 0) FROM repository WHERE owner_id = ?", userID).Get(&totalStars) - - var totalCreditsEarned int64 - _, _ = e.SQL("SELECT COALESCE(SUM(amount), 0) FROM credit_transaction WHERE user_id = ? AND type IN ('deposit', 'reward')", userID).Get(&totalCreditsEarned) - - // Load weights and compute score - weights, err := GetReputationWeights(ctx) - if err != nil { - return err - } - - scoreF := float64(totalStars)*weights.Stars + - float64(bountiesCompleted)*weights.BountiesCompleted + - float64(hackathonWins)*weights.HackathonWins + - float64(grantsReceived)*weights.GrantsReceived + - float64(totalCreditsEarned)*weights.CreditsEarned - score := int64(math.Round(scoreF)) - - // Derive tier - tiers, err := GetReputationTiers(ctx) - if err != nil { - return err - } - - rep.Score = score - rep.BountiesCompleted = int(bountiesCompleted) - rep.HackathonWins = int(hackathonWins) - rep.GrantsReceived = int(grantsReceived) - rep.TotalStars = totalStars - rep.TotalCreditsEarned = totalCreditsEarned - rep.Tier = deriveTier(score, tiers) - - return hackforger_model.UpdateReputation(ctx, rep) -} - -// RecalculateAllReputations recalculates all users with reputation records. -func RecalculateAllReputations(ctx context.Context) error { - var reps []*hackforger_model.Reputation - if err := db.GetEngine(ctx).Find(&reps); err != nil { - return err - } - for _, rep := range reps { - if err := RecalculateReputation(ctx, rep.UserID); err != nil { - log.Error("RecalculateReputation(user=%d): %v", rep.UserID, err) - } - } - return nil -} -``` - -- [ ] **Step 2: Write tests** - -```go -// services/hackforger/reputation_test.go -package hackforger - -import ( - "testing" - - "forgejo.org/models/db" - hackforger_model "forgejo.org/models/hackforger" - "forgejo.org/models/unittest" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestDeriveTier(t *testing.T) { - tiers := []ReputationTier{ - {Name: "Bronze", Min: 0}, - {Name: "Silver", Min: 50}, - {Name: "Gold", Min: 200}, - {Name: "Diamond", Min: 500}, - } - assert.Equal(t, "Bronze", deriveTier(0, tiers)) - assert.Equal(t, "Bronze", deriveTier(49, tiers)) - assert.Equal(t, "Silver", deriveTier(50, tiers)) - assert.Equal(t, "Gold", deriveTier(200, tiers)) - assert.Equal(t, "Diamond", deriveTier(500, tiers)) - assert.Equal(t, "Diamond", deriveTier(9999, tiers)) -} - -func TestRecalculateReputation(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - // User 1 exists in test fixtures. - err := RecalculateReputation(db.DefaultContext, 1) - require.NoError(t, err) - - rep, err := hackforger_model.GetOrCreateReputation(db.DefaultContext, 1) - require.NoError(t, err) - assert.True(t, rep.Score >= 0, "score should be non-negative") - assert.NotEmpty(t, rep.Tier) -} -``` - -- [ ] **Step 3: Run tests** - -Run: `go test ./services/hackforger/... -run TestDeriveTier -v && go test ./services/hackforger/... -run TestRecalculateReputation -v` - -- [ ] **Step 4: Commit** - -```bash -git add services/hackforger/reputation.go services/hackforger/reputation_test.go -git commit -m "feat(reputation): add reputation calculation service with configurable weights" -``` - ---- - -### Task 11: Cron Task - -**Files:** -- Modify: `services/cron/tasks_hackforger.go` (file already exists with 4 task stubs) - -- [ ] **Step 1: Fill in the existing `registerHackforgerReputationRecalc` stub** - -The file `services/cron/tasks_hackforger.go` already has a skeleton for `registerHackforgerReputationRecalc`. Update only this function's body to call the actual service: - -```go -func registerHackforgerReputationRecalc() { - RegisterTaskFatal("hackforger_reputation_recalc", &BaseConfig{ - Enabled: true, - RunAtStart: false, - Schedule: "@every 1h", - }, func(ctx context.Context, _ *user_model.User, _ Config) error { - return hackforger_service.RecalculateAllReputations(ctx) - }) -} -``` - -Add the import for `hackforger_service "forgejo.org/services/hackforger"` if not present. Do NOT modify the other 3 task registrations (`hackathonStatus`, `bountyExpiry`, `grantDeadline`). - -- [ ] **Step 2: Verify compilation** - -Run: `go build ./services/cron/...` - -- [ ] **Step 3: Commit** - -```bash -git add services/cron/tasks_hackforger.go -git commit -m "feat(cron): implement reputation recalculation cron task (hourly)" -``` - ---- - -### Task 12: Reputation API Endpoints - -**Files:** -- Create: `routers/api/v1/hackforger/reputation.go` -- Modify: `routers/api/v1/api.go` (add route registration) - -- [ ] **Step 1: Create API handler file** - -```go -// routers/api/v1/hackforger/reputation.go -package hackforger - -import ( - "net/http" - - "forgejo.org/models/db" - hackforger_model "forgejo.org/models/hackforger" - user_model "forgejo.org/models/user" - "forgejo.org/services/context" - hackforger_service "forgejo.org/services/hackforger" -) - -// GetUserReputation returns reputation data for a user. -func GetUserReputation(ctx *context.APIContext) { - username := ctx.PathParam("username") - u, err := user_model.GetUserByName(ctx, username) - if err != nil { - ctx.NotFound("GetUserByName", err) - return - } - rep, err := hackforger_model.GetOrCreateReputation(ctx, u.ID) - if err != nil { - ctx.Error(http.StatusInternalServerError, "GetOrCreateReputation", err) - return - } - ctx.JSON(http.StatusOK, map[string]any{ - "user_id": u.ID, - "username": u.Name, - "score": rep.Score, - "tier": rep.Tier, - "bounties_completed": rep.BountiesCompleted, - "hackathon_wins": rep.HackathonWins, - "grants_received": rep.GrantsReceived, - "total_stars": rep.TotalStars, - "credits_earned": rep.TotalCreditsEarned, - }) -} - -// GetReputationLeaderboard returns top users by reputation score. -func GetReputationLeaderboard(ctx *context.APIContext) { - limit := ctx.FormInt("limit") - if limit < 1 || limit > 100 { - limit = 50 - } - records, err := hackforger_model.ReputationLeaderboard(ctx, limit) - if err != nil { - ctx.Error(http.StatusInternalServerError, "ReputationLeaderboard", err) - return - } - - // Batch-load usernames (avoid N+1 queries) - userIDs := make([]int64, 0, len(records)) - for _, r := range records { - userIDs = append(userIDs, r.UserID) - } - userMap := make(map[int64]*user_model.User) - if len(userIDs) > 0 { - var users []*user_model.User - if err := db.GetEngine(ctx).In("id", userIDs).Find(&users); err == nil { - for _, u := range users { - userMap[u.ID] = u - } - } - } - - items := make([]map[string]any, 0, len(records)) - for _, r := range records { - username := "" - if u, ok := userMap[r.UserID]; ok { - username = u.Name - } - items = append(items, map[string]any{ - "user_id": r.UserID, - "username": username, - "score": r.Score, - "tier": r.Tier, - }) - } - ctx.JSON(http.StatusOK, items) -} - -// AdminRecalculateReputation triggers recalculation for a single user. -func AdminRecalculateReputation(ctx *context.APIContext) { - username := ctx.PathParam("username") - u, err := user_model.GetUserByName(ctx, username) - if err != nil { - ctx.NotFound("GetUserByName", err) - return - } - if err := hackforger_service.RecalculateReputation(ctx, u.ID); err != nil { - ctx.Error(http.StatusInternalServerError, "RecalculateReputation", err) - return - } - ctx.JSON(http.StatusOK, map[string]string{"status": "ok"}) -} -``` - -- [ ] **Step 2: Register routes in api.go** - -In `routers/api/v1/api.go`, inside the `/hackforger` group (around line 1800), add: - -```go -// Reputation routes -m.Group("/reputation", func() { - m.Get("/users/{username}", hackforger_api.GetUserReputation) - m.Get("/leaderboard", hackforger_api.GetReputationLeaderboard) - m.Post("/recalculate/{username}", reqToken(), reqSiteAdmin(), hackforger_api.AdminRecalculateReputation) -}) -``` - -- [ ] **Step 3: Verify compilation** - -Run: `go build ./routers/api/v1/...` - -- [ ] **Step 4: Commit** - -```bash -git add routers/api/v1/hackforger/reputation.go routers/api/v1/api.go -git commit -m "feat(api): add reputation API endpoints (user, leaderboard, admin recalc)" -``` - ---- - -### Task 13: Admin Web Routes + Template - -**Files:** -- Create: `routers/web/hackforger/reputation.go` -- Create: `templates/admin/hackforger/reputation.tmpl` -- Modify: `routers/web/web.go` (add admin routes) - -- [ ] **Step 1: Create admin web handler** - -```go -// routers/web/hackforger/reputation.go -package hackforger - -import ( - "net/http" - - hackforger_model "forgejo.org/models/hackforger" - "forgejo.org/services/context" - hackforger_service "forgejo.org/services/hackforger" -) - -// AdminReputation renders the reputation settings page. -func AdminReputation(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("hackforger.admin.reputation") - ctx.Data["PageIsAdmin"] = true - ctx.Data["PageIsAdminHackforgerReputation"] = true - - weights := hackforger_model.GetSettingWithDefault(ctx, "reputation.weights", hackforger_service.DefaultWeightsJSON()) - tiers := hackforger_model.GetSettingWithDefault(ctx, "reputation.tiers", hackforger_service.DefaultTiersJSON()) - ctx.Data["Weights"] = weights - ctx.Data["Tiers"] = tiers - - ctx.HTML(http.StatusOK, "admin/hackforger/reputation") -} - -// AdminReputationPost saves reputation settings. -func AdminReputationPost(ctx *context.Context) { - weights := ctx.FormString("weights") - tiers := ctx.FormString("tiers") - - if weights != "" { - if err := hackforger_model.SetSetting(ctx, "reputation.weights", weights); err != nil { - ctx.ServerError("SetSetting weights", err) - return - } - } - if tiers != "" { - if err := hackforger_model.SetSetting(ctx, "reputation.tiers", tiers); err != nil { - ctx.ServerError("SetSetting tiers", err) - return - } - } - - ctx.Flash.Success(ctx.Tr("hackforger.admin.reputation.save_success")) - ctx.Redirect(ctx.Req.URL.Path) -} - -// AdminReputationRecalc triggers a full recalculation. -func AdminReputationRecalc(ctx *context.Context) { - // Use graceful context — HTTP request context is canceled after response. - go hackforger_service.RecalculateAllReputations(graceful.GetManager().HammerContext()) - ctx.Flash.Success(ctx.Tr("hackforger.admin.reputation.recalc_success")) - ctx.Redirect("/admin/hackforger/reputation") -} -``` - -**Note:** Export `defaultWeightsJSON` and `defaultTiersJSON` from `services/hackforger/reputation.go` as `DefaultWeightsJSON()` and `DefaultTiersJSON()` functions. - -- [ ] **Step 2: Create admin template** - -Create `templates/admin/hackforger/reputation.tmpl` — a form page with textarea fields for weights JSON and tiers JSON, plus a "Recalculate All" button. Follow the existing admin page pattern (e.g., `templates/admin/config.tmpl` for layout reference). - -- [ ] **Step 3: Register routes in web.go** - -Inside the admin group in `web.go` (after the HackForger Admin Credits block around line 923): - -```go -// ***** START: HackForger Admin Reputation ***** -m.Group("/hackforger/reputation", func() { - m.Get("", hackforger_web.AdminReputation) - m.Post("", hackforger_web.AdminReputationPost) - m.Post("/recalc", hackforger_web.AdminReputationRecalc) -}) -// ***** END: HackForger Admin Reputation ***** -``` - -- [ ] **Step 4: Verify compilation + build** - -Run: `TAGS="bindata sqlite sqlite_unlock_notify" make backend` - -- [ ] **Step 5: Commit** - -```bash -git add routers/web/hackforger/reputation.go templates/admin/hackforger/reputation.tmpl routers/web/web.go services/hackforger/reputation.go -git commit -m "feat(admin): add reputation settings admin page with weight/tier config" -``` - ---- - -## Chunk 4: Frontend Integration - -### Task 14: Template Helper Functions - -**Files:** -- Modify: `modules/templates/helper.go` - -- [ ] **Step 1: Add HackforgerEntityURL and HackforgerActionIcon to NewFuncMap()** - -In the `NewFuncMap()` return map, add: - -```go -"HackforgerEntityURL": func(entityType, slug string) string { - switch entityType { - case "hackathon": - return setting.AppSubURL + "/hackathon/" + url.PathEscape(slug) - case "grant": - return setting.AppSubURL + "/grants/" + url.PathEscape(slug) - default: - return "" - } -}, -"HackforgerActionIcon": func(opType int) string { - switch { - case opType >= 30 && opType <= 33: return "octicon-rocket" // hackathon - case opType >= 34 && opType <= 38: return "octicon-gift" // bounty - case opType == 43: return "octicon-gift" // bounty_paid - case opType >= 39 && opType <= 41: return "octicon-heart" // grant - case opType == 42: return "octicon-credit-card" // credits - case opType >= 50 && opType <= 51: return "octicon-rocket" // hackathon lifecycle - case opType >= 52 && opType <= 53: return "octicon-gift" // bounty lifecycle - case opType >= 54 && opType <= 57: return "octicon-heart" // grant lifecycle - default: return "octicon-pulse" - } -}, -``` - -Add `"net/url"` to imports if not already present. - -- [ ] **Step 2: Verify compilation** - -Run: `go build ./modules/templates/...` - -- [ ] **Step 3: Commit** - -```bash -git add modules/templates/helper.go -git commit -m "feat(templates): add HackforgerEntityURL and HackforgerActionIcon helpers" -``` - ---- - -### Task 15: Community Feeds Template - -**Files:** -- Create: `templates/hackforger/feed/community_feeds.tmpl` - -- [ ] **Step 1: Create the community feeds template** - -```html -
-{{range .HackforgerFeeds}} -
-
- {{ctx.AvatarUtils.Avatar .ActUser 32}} -
-
-
- {{if .ActUser}} - {{.ActUser.GetDisplayName}} - {{end}} - {{if eq .OpType 30}}{{ctx.Locale.Tr "hackforger.feed.hackathon_created" .EntityName (HackforgerEntityURL .EntityType .EntitySlug)}} - {{else if eq .OpType 31}}{{ctx.Locale.Tr "hackforger.feed.hackathon_registered" .EntityName (HackforgerEntityURL .EntityType .EntitySlug)}} - {{else if eq .OpType 32}}{{ctx.Locale.Tr "hackforger.feed.hackathon_submitted" .EntityName (HackforgerEntityURL .EntityType .EntitySlug)}} - {{else if eq .OpType 33}}{{ctx.Locale.Tr "hackforger.feed.hackathon_scored" .EntityName (HackforgerEntityURL .EntityType .EntitySlug)}} - {{else if eq .OpType 34}}{{ctx.Locale.Tr "hackforger.feed.bounty_created" .EntityName (HackforgerEntityURL .EntityType .EntitySlug)}} - {{else if eq .OpType 35}}{{ctx.Locale.Tr "hackforger.feed.bounty_claimed" .EntityName (HackforgerEntityURL .EntityType .EntitySlug)}} - {{else if eq .OpType 36}}{{ctx.Locale.Tr "hackforger.feed.bounty_delivered" .EntityName (HackforgerEntityURL .EntityType .EntitySlug)}} - {{else if eq .OpType 37}}{{ctx.Locale.Tr "hackforger.feed.bounty_completed" .EntityName (HackforgerEntityURL .EntityType .EntitySlug)}} - {{else if eq .OpType 38}}{{ctx.Locale.Tr "hackforger.feed.bounty_winners_selected" .EntityName (HackforgerEntityURL .EntityType .EntitySlug)}} - {{else if eq .OpType 39}}{{ctx.Locale.Tr "hackforger.feed.grant_round_created" .EntityName (HackforgerEntityURL .EntityType .EntitySlug)}} - {{else if eq .OpType 40}}{{ctx.Locale.Tr "hackforger.feed.grant_project_submitted" .EntityName (HackforgerEntityURL .EntityType .EntitySlug)}} - {{else if eq .OpType 41}}{{ctx.Locale.Tr "hackforger.feed.grant_awarded" .EntityName (HackforgerEntityURL .EntityType .EntitySlug)}} - {{else if eq .OpType 42}}{{ctx.Locale.Tr "hackforger.feed.credits_redeemed"}} - {{else if eq .OpType 43}}{{ctx.Locale.Tr "hackforger.feed.bounty_paid" .EntityName (HackforgerEntityURL .EntityType .EntitySlug)}} - {{else if eq .OpType 50}}{{ctx.Locale.Tr "hackforger.feed.hackathon_phase_changed" .EntityName (HackforgerEntityURL .EntityType .EntitySlug)}} - {{else if eq .OpType 51}}{{ctx.Locale.Tr "hackforger.feed.hackathon_finalized" .EntityName (HackforgerEntityURL .EntityType .EntitySlug)}} - {{else if eq .OpType 52}}{{ctx.Locale.Tr "hackforger.feed.bounty_expired" .EntityName (HackforgerEntityURL .EntityType .EntitySlug)}} - {{else if eq .OpType 53}}{{ctx.Locale.Tr "hackforger.feed.bounty_cancelled" .EntityName (HackforgerEntityURL .EntityType .EntitySlug)}} - {{else if eq .OpType 54}}{{ctx.Locale.Tr "hackforger.feed.grant_round_opened" .EntityName (HackforgerEntityURL .EntityType .EntitySlug)}} - {{else if eq .OpType 55}}{{ctx.Locale.Tr "hackforger.feed.grant_round_closed" .EntityName (HackforgerEntityURL .EntityType .EntitySlug)}} - {{else if eq .OpType 56}}{{ctx.Locale.Tr "hackforger.feed.grant_round_finalized" .EntityName (HackforgerEntityURL .EntityType .EntitySlug)}} - {{else if eq .OpType 57}}{{ctx.Locale.Tr "hackforger.feed.grant_round_cancelled" .EntityName (HackforgerEntityURL .EntityType .EntitySlug)}} - {{end}} - {{DateUtils.TimeSince .CreatedUnix}} -
-
-
- {{svg (HackforgerActionIcon .OpType) 32 "text grey tw-mr-1"}} -
-
-{{end}} -{{template "base/paginate" .}} -
-``` - -- [ ] **Step 2: Build to verify template is valid** - -Run: `TAGS="bindata sqlite sqlite_unlock_notify" make backend` - -- [ ] **Step 3: Commit** - -```bash -git add templates/hackforger/feed/community_feeds.tmpl -git commit -m "feat(feed): add community feeds template for HackForger events" -``` - ---- - -### Task 16: Dashboard Community Tab - -**Files:** -- Modify: `templates/user/dashboard/dashboard.tmpl` -- Modify: `routers/web/user/home.go` - -- [ ] **Step 1: Add tab bar to dashboard.tmpl** - -Replace the feed section (lines 7-16) with the tab bar + conditional content from spec section 4.1. - -- [ ] **Step 2: Add community feed query to Dashboard() in home.go** - -Add `?feed=` param handling and the community query branch from spec section 4.2. Import `hackforger_model`. - -- [ ] **Step 3: Build and verify** - -Run: `TAGS="bindata sqlite sqlite_unlock_notify" make backend` - -- [ ] **Step 4: Commit** - -```bash -git add templates/user/dashboard/dashboard.tmpl routers/web/user/home.go -git commit -m "feat(dashboard): add Community tab with HackForger feed" -``` - ---- - -### Task 17: Reputation Templates (Card + Detail + Leaderboard) - -**Files:** -- Create: `templates/hackforger/reputation/card.tmpl` -- Create: `templates/hackforger/reputation/detail.tmpl` -- Create: `templates/hackforger/reputation/leaderboard.tmpl` - -- [ ] **Step 1: Create reputation sidebar card** - -`templates/hackforger/reputation/card.tmpl` — compact card showing tier badge, score, top metrics. Follow Forgejo's card styling patterns. - -- [ ] **Step 2: Create reputation detail page** - -`templates/hackforger/reputation/detail.tmpl` — full metrics table, rank, link to leaderboard. - -- [ ] **Step 3: Create leaderboard page** - -`templates/hackforger/reputation/leaderboard.tmpl` — table of top users with score/tier. Include explore page wrapper (base/head, base/footer). - -- [ ] **Step 4: Build** - -Run: `TAGS="bindata sqlite sqlite_unlock_notify" make backend` - -- [ ] **Step 5: Commit** - -```bash -git add templates/hackforger/reputation/card.tmpl templates/hackforger/reputation/detail.tmpl templates/hackforger/reputation/leaderboard.tmpl -git commit -m "feat(reputation): add card, detail, and leaderboard templates" -``` - ---- - -### Task 18: Profile Integration - -**Files:** -- Modify: `templates/user/overview/header.tmpl` (add 2 tab links) -- Modify: `templates/user/profile.tmpl` (add tab content) -- Modify: `routers/web/user/profile.go` (add tab data loading) -- Modify: `routers/web/web.go` (add leaderboard route) - -- [ ] **Step 1: Add Community + Reputation tabs to profile header** - -In `templates/user/overview/header.tmpl`, after the "activity" tab link (around line 43), add the two new tab links from spec section 5.2. - -- [ ] **Step 2: Add tab content to profile.tmpl** - -In `templates/user/profile.tmpl`, add `community` and `reputation` tab content branches before the `{{else}}` default case (around line 61). See spec section 5.3. - -- [ ] **Step 3: Add reputation sidebar card injection** - -In `templates/user/profile.tmpl`, add `{{if .Reputation}}{{template "hackforger/reputation/card" .}}{{end}}` after `{{template "shared/user/profile_big_avatar" .}}` (line 8). - -- [ ] **Step 4: Add tab data loading in profile.go** - -In `routers/web/user/profile.go` `prepareUserProfileTabData()`: -- Before the `switch tab` block, load reputation unconditionally -- Add `case "community"` and `case "reputation"` branches -- See spec section 5.3 for exact code - -- [ ] **Step 5: Add leaderboard route in web.go** - -Inside the explore group (around line 520), add: -```go -m.Get("/reputation", hackforger_web.ExploreReputation) -``` - -Add `ExploreReputation` handler in `routers/web/hackforger/reputation.go` that loads leaderboard data and renders the template. - -- [ ] **Step 6: Build and verify** - -Run: `TAGS="bindata sqlite sqlite_unlock_notify" make backend` - -- [ ] **Step 7: Commit** - -```bash -git add templates/user/overview/header.tmpl templates/user/profile.tmpl routers/web/user/profile.go routers/web/web.go routers/web/hackforger/reputation.go -git commit -m "feat(profile): add Community tab, Reputation tab, sidebar card, and leaderboard" -``` - ---- - -## Chunk 5: E2E Test Prompt - -### Task 19: E2E Test Prompt Document - -**Files:** -- Create: `docs/tests/e2e/phase2-reputation-feed-profile.md` - -- [ ] **Step 1: Write E2E test prompt with test scenarios** - -Cover: -1. Dashboard: Code tab shows only Git events; Community tab shows HackForger events -2. Profile: Activity tab (Git only), Community tab (HackForger), Reputation tab (score + tier) -3. Reputation sidebar card visible on all profile tabs -4. Admin: Reputation settings page — save weights, trigger recalc -5. API: `/api/v1/hackforger/feed?type=global`, `/api/v1/hackforger/reputation/users/{name}`, `/api/v1/hackforger/reputation/leaderboard` -6. Feed events: Create a hackathon → verify event appears in Community tab -7. Explore: `/explore/reputation` shows leaderboard - -Include expected results and E2E report template (Round format matching existing docs). - -- [ ] **Step 2: Commit** - -```bash -git add docs/tests/e2e/phase2-reputation-feed-profile.md -git commit -m "docs: add Phase 2 E2E test prompt for reputation + feed + profile" -``` - ---- - -## Summary - -| Chunk | Tasks | Key Deliverables | -|-------|-------|-----------------| -| 1: Data Layer | 1-5 | Models, migration, i18n | -| 2: Feed Refactor | 6-9 | Notifier rewrite, caller updates, API, template cleanup | -| 3: Reputation | 10-13 | Service, cron, API, admin page | -| 4: Frontend | 14-18 | Templates, dashboard tab, profile tabs, leaderboard | -| 5: E2E | 19 | Test prompt document | - -**Total: 19 tasks, ~11 new files, ~11 modified Forgejo files.** diff --git a/docs/superpowers/plans/2026-04-01-ui-modernization.md b/docs/superpowers/plans/2026-04-01-ui-modernization.md deleted file mode 100644 index 18f6daa519..0000000000 --- a/docs/superpowers/plans/2026-04-01-ui-modernization.md +++ /dev/null @@ -1,452 +0,0 @@ -# UI Modernization Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace Fomantic UI classes in all 32 HackForger templates with Tailwind-first `.hf-*` component classes for a distinctive, developer-focused visual identity with light/dark theme support. - -**Architecture:** Two new CSS files define color tokens and component classes. A status_badge helper template centralizes status→color mapping. Templates are migrated module-by-module. Vue components update template section only. - -**Tech Stack:** Tailwind CSS 3.4 (tw- prefix), Go html/template SSR, CSS custom properties, Forgejo Octicon SVGs - -**Spec:** `docs/superpowers/specs/2026-04-01-ui-modernization-design.md` - ---- - -## Chunk 1: CSS Foundation + Helper Template - -### Task 1: Create color token CSS file - -**Files:** -- Create: `web_src/css/hackforger-colors.css` - -- [ ] **Step 1: Create hackforger-colors.css with light/dark color tokens** - -```css -/* HackForger color tokens — light mode defaults, dark mode overrides */ -:root { - --hf-green-bg: #d1f5dc; - --hf-green-text: #14532d; - --hf-blue-bg: #c8dffc; - --hf-blue-text: #0c3b7c; - --hf-red-bg: #fcd5d0; - --hf-red-text: #7f1d1d; - --hf-yellow-bg: #fde68a; - --hf-yellow-text: #713f12; - --hf-purple-bg: #e4ccf8; - --hf-purple-text: #4a1d7a; - --hf-teal-bg: #c4f1f9; - --hf-teal-text: #0e5e6f; - --hf-neutral-bg: #e4e7ec; - --hf-neutral-text: #374151; -} - -[data-theme="gitea-dark"], -[data-theme="gitea-auto"][data-system-theme="dark"], -[data-theme="forgejo-dark"], -[data-theme="forgejo-auto"][data-system-theme="dark"] { - --hf-green-bg: rgba(86,211,100,0.18); - --hf-green-text: #56d364; - --hf-blue-bg: rgba(121,192,255,0.16); - --hf-blue-text: #79c0ff; - --hf-red-bg: rgba(255,123,114,0.16); - --hf-red-text: #ff7b72; - --hf-yellow-bg: rgba(227,179,65,0.18); - --hf-yellow-text: #e3b341; - --hf-purple-bg: rgba(188,140,255,0.16); - --hf-purple-text: #bc8cff; - --hf-teal-bg: rgba(86,212,221,0.16); - --hf-teal-text: #56d4dd; - --hf-neutral-bg: #30363d; - --hf-neutral-text: #9eaab6; -} -``` - -**Deviation from spec:** The spec says `@media (prefers-color-scheme)` but Forgejo actually uses `data-theme` attribute on ``, not media queries. The selectors above are correct for Forgejo's theming system. Check `templates/base/head.tmpl` for the theme attribute pattern. Do NOT use `@media (prefers-color-scheme)` — it won't work with Forgejo's theme picker. - -- [ ] **Step 2: Commit** - -```bash -git add web_src/css/hackforger-colors.css -git commit -m "feat(ui): add HackForger color token CSS variables" -``` - -### Task 2: Create component class CSS file - -**Files:** -- Create: `web_src/css/hackforger.css` - -- [ ] **Step 1: Create hackforger.css with all component classes** - -Components to define (see spec for exact properties): -- `.hf-badge` + variants `.hf-badge-green`, `.hf-badge-blue`, `.hf-badge-red`, `.hf-badge-yellow`, `.hf-badge-purple`, `.hf-badge-teal`, `.hf-badge-neutral` -- `.hf-card`, `.hf-card-head`, `.hf-card-body` -- `.hf-timeline`, `.hf-timeline-step`, `.hf-step-done`, `.hf-step-active`, `.hf-step-upcoming`, `.hf-step-name`, `.hf-step-date`, `.hf-step-status` -- `.hf-stat`, `.hf-stat-value`, `.hf-stat-label` -- `.hf-row`, `.hf-row-left` -- `.hf-btn`, `.hf-btn-primary`, `.hf-btn-secondary` -- `.hf-count` - -Each badge variant uses the corresponding `--hf-{color}-bg` and `--hf-{color}-text` tokens. Surface colors use Forgejo variables (`var(--color-box-body)`, `var(--color-box-header)`, `var(--color-secondary)`). - -- [ ] **Step 2: Commit** - -```bash -git add web_src/css/hackforger.css -git commit -m "feat(ui): add HackForger component CSS classes" -``` - -### Task 3: Import CSS files in index.css - -**Files:** -- Modify: `web_src/css/index.css` (add 2 import lines before `@tailwind utilities`) - -- [ ] **Step 1: Add imports before the @tailwind utilities line** - -```css -@import "./hackforger-colors.css"; -@import "./hackforger.css"; - -@tailwind utilities; -``` - -- [ ] **Step 2: Commit** - -```bash -git add web_src/css/index.css -git commit -m "feat(ui): import HackForger CSS in main stylesheet" -``` - -### Task 4: Create status_badge helper template - -**Files:** -- Create: `templates/hackforger/helpers/status_badge.tmpl` - -- [ ] **Step 1: Create the status badge helper** - -This template renders a badge span given `.Status` (int) and `.Type` (string: "hackathon", "bounty", "registration", "tier"). - -The mapping logic: -- hackathon: 0→neutral, 1→green, 2→blue, 3→yellow, 4→neutral, 5→red -- bounty: 0→green, 1→blue, 2→yellow, 3→purple, 4→teal, 5→red, 6→neutral -- registration: 0→yellow, 1→green, 2→red -- tier (string match): "Diamond"→purple, "Gold"→yellow, "Silver"→neutral, "Bronze"→yellow - -Output: `{{.Label}}` - -Note: Go templates don't support function-style helpers easily. Instead, use a `{{define}}` block with `{{if}}` chains, invoked via `{{template "hackforger/helpers/status_badge" dict "Status" .Status "Type" "hackathon" "Label" .StatusLabel}}`. - -Check if Forgejo's template system supports `dict` helper — look for `dict` usage in existing templates. If not available, use separate define blocks per type: `status_badge_hackathon`, `status_badge_bounty`, etc. - -- [ ] **Step 2: Commit** - -```bash -git add templates/hackforger/helpers/status_badge.tmpl -git commit -m "feat(ui): add status_badge helper template" -``` - ---- - -## Chunk 2: Hackathon Templates (7 files) - -### Task 5: Migrate hackathon/view.tmpl - -**Files:** -- Modify: `templates/hackforger/hackathon/view.tmpl` - -- [ ] **Step 1: Rewrite the template** - -Key changes: -- Header: `tw-flex` layout stays, replace `ui label` status badge → use status_badge helper -- Timeline section: replace `ui segment` + `ui three column grid` → `.hf-timeline` with `.hf-step-done`/`.hf-step-active`/`.hf-step-upcoming` based on current hackathon status -- Description/Prizes: replace `ui segment` → `.hf-card` with `.hf-card-head` + `.hf-card-body` -- Tracks: replace `ui divided list` → `.hf-card` with `.hf-row` items -- Registration form: keep `ui form` and `ui dropdown` (Fomantic JS-dependent), wrap in `.hf-card` -- Participants: replace `ui divided list` → `.hf-card` with `.hf-row` items, registration status badges via helper -- Submissions table: keep as table (truly tabular data), wrap in `.hf-card`, replace `ui celled table` → minimal table styling -- Buttons: replace `ui button` → `.hf-btn .hf-btn-primary` / `.hf-btn-secondary` - -- [ ] **Step 2: Commit** - -### Task 6: Migrate hackathon/explore.tmpl - -**Files:** -- Modify: `templates/hackforger/hackathon/explore.tmpl` - -- [ ] **Step 1: Rewrite** - -- Replace `ui relaxed divided list` → `.hf-card` with `.hf-row` items -- Replace inline status color conditionals → status_badge helper -- Replace `ui placeholder segment` empty state → `.hf-card` with centered icon + text -- Keep `{{template "hackforger/explore_search" .}}` and `{{template "base/paginate" .}}` unchanged - -- [ ] **Step 2: Commit** - -### Task 7: Migrate hackathon/manage.tmpl - -**Files:** -- Modify: `templates/hackforger/hackathon/manage.tmpl` - -- [ ] **Step 1: Rewrite** - -- Phase Control: replace `ui segment` → `.hf-card`, `ui label` → status_badge, `ui green/blue/orange button` → `.hf-btn` variants -- Tracks section: replace `ui segment` → `.hf-card`, keep `ui form` for add track form -- Scoring Criteria: replace `ui segment` + `ui celled table` → `.hf-card` with table inside (truly tabular — keep table but remove `ui celled`) -- Track Scoring Overrides: same pattern — `.hf-card` wrapper, keep table + `ui form` for inline edit -- Registrations: `.hf-card`, replace action buttons → `.hf-btn` -- Judges: `.hf-card` with `.hf-row` items, keep `ui form` for add judge -- Finalize Preview: `.hf-card`, keep table for ranked results - -- [ ] **Step 2: Commit** - -### Task 8: Migrate hackathon/new.tmpl, submit.tmpl, leaderboard.tmpl, judge.tmpl - -**Files:** -- Modify: `templates/hackforger/hackathon/new.tmpl` -- Modify: `templates/hackforger/hackathon/submit.tmpl` -- Modify: `templates/hackforger/hackathon/leaderboard.tmpl` -- Modify: `templates/hackforger/hackathon/judge.tmpl` - -- [ ] **Step 1: Migrate new.tmpl and submit.tmpl** - -Both are form pages. Replace `ui segment` wrappers → `.hf-card`. Keep `ui form` class. Replace `ui button` → `.hf-btn`. - -- [ ] **Step 2: Migrate leaderboard.tmpl** - -Replace `ui celled table` → table inside `.hf-card`. Use `.hf-badge` for tier display. Highlight #1 row with green-tinted background. - -- [ ] **Step 3: Migrate judge.tmpl** - -Minimal template — just a mount point for Vue component. Replace `ui container` wrapper with `.hf-card` if needed. The Vue component (JudgeScoreCard) handles its own rendering. - -- [ ] **Step 4: Commit all four** - -```bash -git add templates/hackforger/hackathon/ -git commit -m "feat(ui): migrate all hackathon templates to hf-* classes" -``` - ---- - -## Chunk 3: Bounty + Credits Templates (12 files) - -### Task 9: Migrate bounty templates (4 files) - -**Files:** -- Modify: `templates/hackforger/bounty/panel.tmpl` -- Modify: `templates/hackforger/bounty/badge.tmpl` -- Modify: `templates/hackforger/bounty/explore.tmpl` -- Modify: `templates/hackforger/bounty/new.tmpl` - -- [ ] **Step 1: Migrate panel.tmpl** - -Key changes: -- Replace `ui segment` → remove outer segment (panel is embedded in issue sidebar) -- Replace `ui small label {color}` status badge → status_badge helper with type "bounty" -- Replace `ui mini label` reward chips → `.hf-badge` with border style (reward chips, not status) -- Winner list: `.hf-row` items -- Vue mount point (`#hackforger-bounty-panel`) stays unchanged - -- [ ] **Step 2: Migrate badge.tmpl, explore.tmpl, new.tmpl** - -- `badge.tmpl`: simple inline badge, replace Fomantic label → `.hf-badge` -- `explore.tmpl`: listing page, similar pattern to hackathon/explore -- `new.tmpl`: form page, keep `ui form`, wrap in `.hf-card` - -- [ ] **Step 3: Commit** - -### Task 10: Migrate credits templates (8 files) - -**Files:** -- Modify: `templates/hackforger/credits/dashboard.tmpl` -- Modify: `templates/hackforger/credits/overview.tmpl` -- Modify: `templates/hackforger/credits/redeem.tmpl` -- Modify: `templates/hackforger/credits/orders.tmpl` -- Modify: `templates/hackforger/credits/admin/credits.tmpl` -- Modify: `templates/hackforger/credits/admin/keys.tmpl` -- Modify: `templates/hackforger/credits/admin/options.tmpl` -- Modify: `templates/hackforger/credits/admin/orders.tmpl` - -- [ ] **Step 1: Migrate dashboard.tmpl** - -Key changes: -- Replace `ui statistic` → `.hf-stat` with `.hf-stat-value` and `.hf-stat-label` -- Replace `ui celled table` → table inside `.hf-card` -- Green/red amount colors: use `tw-text-green` / `tw-text-red` (Tailwind mapped to Forgejo CSS vars) -- Replace `ui placeholder segment` empty state → `.hf-card` with centered content - -- [ ] **Step 2: Migrate overview.tmpl, redeem.tmpl, orders.tmpl** - -- overview: stats + transaction list, same patterns as dashboard -- redeem: form page, keep `ui form`, wrap in `.hf-card` -- orders: table listing, `.hf-card` wrapper - -- [ ] **Step 3: Migrate admin templates (4 files)** - -Admin pages use `ui celled table` extensively. Replace with tables inside `.hf-card`. Replace `ui button` → `.hf-btn`. These are internal-facing so lower visual priority. - -- [ ] **Step 4: Commit** - -```bash -git add templates/hackforger/bounty/ templates/hackforger/credits/ -git commit -m "feat(ui): migrate bounty + credits templates to hf-* classes" -``` - ---- - -## Chunk 4: Grants + Reputation + Explore + Vue (remaining) - -### Task 11: Migrate grants templates (7 files) - -**Files:** -- Modify: All 7 files in `templates/hackforger/grants/` - -- [ ] **Step 1: Migrate detail.tmpl** - -Key change: replace `ui six steps` progress bar → `.hf-timeline` (reuse same component, adapt for grant statuses: draft → open → review → finalized → distributed). Replace `ui segment` → `.hf-card`. Replace `ui label` → `.hf-badge`. - -- [ ] **Step 2: Migrate remaining 6 grant templates** - -- `explore.tmpl`: listing with `.hf-card` + `.hf-row` -- `manage.tmpl`: admin form, keep `ui form`, `.hf-card` wrappers -- `manage_project.tmpl`: detail view, `.hf-card` -- `new.tmpl`: form, keep `ui form` -- `projects.tmpl`: listing, `.hf-card` + `.hf-row` or table -- `submit.tmpl`: form, keep `ui form` - -- [ ] **Step 3: Commit** - -### Task 12: Migrate reputation templates (3 files) - -**Files:** -- Modify: `templates/hackforger/reputation/leaderboard.tmpl` -- Modify: `templates/hackforger/reputation/card.tmpl` -- Modify: `templates/hackforger/reputation/detail.tmpl` - -- [ ] **Step 1: Migrate leaderboard.tmpl** - -Replace `ui celled striped table` → table inside `.hf-card`. Tier badges via status_badge helper with type "tier". Highlight #1 row. - -- [ ] **Step 2: Migrate card.tmpl and detail.tmpl** - -- `card.tmpl`: compact reputation display, use `.hf-badge` for tier -- `detail.tmpl`: user profile reputation, `.hf-card` sections - -- [ ] **Step 3: Commit** - -### Task 13: Migrate explore + feed templates (3 files) - -**Files:** -- Modify: `templates/hackforger/explore.tmpl` -- Modify: `templates/hackforger/explore_search.tmpl` -- Modify: `templates/hackforger/feed/community_feeds.tmpl` - -- [ ] **Step 1: Migrate all three** - -- `explore.tmpl`: multi-tab explore page, replace `ui divided items` → `.hf-card` + `.hf-row` -- `explore_search.tmpl`: search form component, minimal changes -- `community_feeds.tmpl`: feed listing, `.hf-card` + `.hf-row` - -- [ ] **Step 2: Commit** - -### Task 14: Update Vue components - -**Files:** -- Modify: `web_src/js/components/hackforger/BountyPanel.vue` (template section only) -- Modify: `web_src/js/components/hackforger/JudgeScoreCard.vue` (template section only) - -- [ ] **Step 1: Update BountyPanel.vue template** - -Replace in `
- {{if eq .Status "pending"}} - - {{end}} - {{ctx.Locale.Tr "hackforger.credits.delivery"}} - {{if and (eq .Status "fulfilled") (ne .DeliveryType "")}} - {{.DeliveryType}} - {{.DeliveryValue}} - {{else}} - - - {{end}} -