From 8c0148e604966f7405e9f4f4226410d7b41f3a51 Mon Sep 17 00:00:00 2001 From: sunba91-su Date: Mon, 8 Jun 2026 16:56:26 +0330 Subject: [PATCH 1/6] ci: add enhanced CI pipeline, release workflow, and proxy support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CI: 4 jobs (lint/build/test+docker) with race detection, coverage artifact, and ghcr.io push on main - Release: on v* tag — vet+test, Docker push with semver+latest tags, auto-generated release notes via softprops/action-gh-release - Dockerfile: proxy ARGs for local builds behind filtered internet - docker-compose.yml: proxy args passthrough from .env - Makefile: proxy-forwarding docker-build target - .env.example: commented proxy vars Closes #9 --- .env.example | 4 +++ .github/workflows/ci.yml | 56 ++++++++++++++++++++++++++++++----- .github/workflows/release.yml | 50 +++++++++++++++++++++++++++++++ Dockerfile | 3 ++ Makefile | 6 +++- docker-compose.yml | 4 +++ 6 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.env.example b/.env.example index a931650..390c9b0 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,7 @@ ROCKETCHAT_BOT_USERNAME=geekbot ROCKETCHAT_BOT_PASSWORD=your-password ROCKETCHAT_MAIN_ADMIN=admin_username STANDUP_DB_PATH=/data/standup-bot.db + +# http_proxy=http://proxy:port +# https_proxy=http://proxy:port +# no_proxy=localhost,127.0.0.1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2af188d..377c769 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,9 +4,22 @@ on: push: branches: [main] pull_request: - branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + cache: true + - run: go vet ./... + build: runs-on: ubuntu-latest steps: @@ -15,9 +28,38 @@ jobs: with: go-version: "1.22" cache: true - - name: Vet - run: go vet ./... - - name: Build - run: go build ./... - - name: Test - run: go test ./... + - run: go build ./... + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + cache: true + - run: go test ./... -count=1 -race -coverprofile=coverage.out + - uses: actions/upload-artifact@v4 + with: + name: coverage + path: coverage.out + + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Smoke test + run: docker build . + - name: Log in to ghcr.io + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v6 + with: + push: ${{ github.ref == 'refs/heads/main' }} + tags: | + ghcr.io/sunba91-su/roket.chat-geekbot:latest + ghcr.io/sunba91-su/roket.chat-geekbot:${{ github.sha }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..32a46a9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,50 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + cache: true + + - name: Vet and test + run: | + go vet ./... + go test ./... -count=1 -race + + - name: Extract version from tag + id: meta + run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + + - name: Log in to ghcr.io + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + push: true + tags: | + ghcr.io/sunba91-su/roket.chat-geekbot:${{ steps.meta.outputs.VERSION }} + ghcr.io/sunba91-su/roket.chat-geekbot:latest + + - name: Generate release notes + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + name: v${{ steps.meta.outputs.VERSION }} diff --git a/Dockerfile b/Dockerfile index 4ad2bed..3c8e1dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,7 @@ FROM golang:1.22-alpine AS builder +ARG http_proxy +ARG https_proxy +ARG no_proxy RUN apk add --no-cache ca-certificates tzdata && \ adduser -D -u 1001 appuser WORKDIR /src diff --git a/Makefile b/Makefile index 22406c4..3e3b4be 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,11 @@ clean: rm -rf $(BIN_DIR)/ docker-build: - docker build -t $(APP_NAME) . + docker build \ + --build-arg http_proxy \ + --build-arg https_proxy \ + --build-arg no_proxy \ + -t $(APP_NAME) . docker-run: docker compose up -d --build diff --git a/docker-compose.yml b/docker-compose.yml index 08562ac..3b36b7f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,10 @@ services: build: context: . dockerfile: Dockerfile + args: + http_proxy: "${http_proxy:-}" + https_proxy: "${https_proxy:-}" + no_proxy: "${no_proxy:-}" container_name: geekbot restart: unless-stopped env_file: .env From 82794328557e92a642afaa34c3c367cce7f367cd Mon Sep 17 00:00:00 2001 From: sunba91-su Date: Mon, 8 Jun 2026 17:02:02 +0330 Subject: [PATCH 2/6] fix: remove -coverprofile flag from CI test step covdata tool conflicts with -race on Go 1.22 runner. Replace with -cover flag for inline coverage output. --- .github/workflows/ci.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 377c769..e84355f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,11 +38,7 @@ jobs: with: go-version: "1.22" cache: true - - run: go test ./... -count=1 -race -coverprofile=coverage.out - - uses: actions/upload-artifact@v4 - with: - name: coverage - path: coverage.out + - run: go test ./... -count=1 -race -cover docker: runs-on: ubuntu-latest From 5508035b5dc23793623871c8ff5980843791b8a2 Mon Sep 17 00:00:00 2001 From: sunba91-su Date: Mon, 8 Jun 2026 17:07:58 +0330 Subject: [PATCH 3/6] fix: bump builder image to golang:1.25-alpine go.mod requires go 1.25.3 but Dockerfile used 1.22-alpine, causing go mod download to fail with version mismatch. --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 3c8e1dc..63a206e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.22-alpine AS builder +FROM golang:1.25-alpine AS builder ARG http_proxy ARG https_proxy ARG no_proxy From 54dfb90e387f41f9ed5f78907fb3ec80f98cbe65 Mon Sep 17 00:00:00 2001 From: sunba91-su Date: Mon, 8 Jun 2026 17:12:03 +0330 Subject: [PATCH 4/6] fix: remove -cover flag from CI test step covdata tool conflicts with -race on Go 1.22 runner for packages without test files, causing CI to fail with exit code 1. Coverage tracking moved to issue #21. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e84355f..02f67cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: with: go-version: "1.22" cache: true - - run: go test ./... -count=1 -race -cover + - run: go test ./... -count=1 -race docker: runs-on: ubuntu-latest From d18aa3b6a49bce2a0711681ba33416af295f1646 Mon Sep 17 00:00:00 2001 From: sunba91-su Date: Mon, 8 Jun 2026 17:25:44 +0330 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20polish=20command=20framework=20?= =?UTF-8?q?=E2=80=94=20permissions,=20multi-team,=20cancel,=20list,=20dupe?= =?UTF-8?q?=20prevention?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PermissionMember now checks Store.GetTeamsForUser (non-members rejected at registry level) - handleSubmit/handleStatus support --team flag for multi-team users - New /standup cancel command aborts active conversation - New /standup list command shows user's teams - activeOrNewSession prevents duplicate daily submissions - Help output updated with cancel/list entries - 4 new tests: CancelNoConversation, CancelActive, ListTeams, SubmitDuplicateSession Closes #22 --- internal/commands/help.go | 4 + internal/commands/registry.go | 7 ++ internal/commands/standup.go | 139 ++++++++++++++++++++++++++---- internal/commands/standup_test.go | 61 +++++++++++++ 4 files changed, 196 insertions(+), 15 deletions(-) diff --git a/internal/commands/help.go b/internal/commands/help.go index f7ddfe2..7a4f954 100644 --- a/internal/commands/help.go +++ b/internal/commands/help.go @@ -21,6 +21,8 @@ func handleHelp(ctx *Context) error { cmds += " /standup team set timezone \n" cmds += " /standup submit — start daily standup\n" cmds += " /standup status — check submission status\n" + cmds += " /standup cancel — cancel in-progress standup\n" + cmds += " /standup list — list your teams\n" cmds += " /standup report — view latest report\n" cmds += " /standup help — show this message" @@ -31,6 +33,8 @@ func sendHelpForCommand(ctx *Context, cmd string) error { help := map[string]string{ "submit": "Start your daily standup. The bot will DM you with questions.", "status": "Check if you've submitted your standup today.", + "cancel": "Cancel an in-progress standup submission.", + "list": "List teams you belong to.", "report": "Post the latest standup report to the team channel.", } diff --git a/internal/commands/registry.go b/internal/commands/registry.go index 56c8a20..0f59c5b 100644 --- a/internal/commands/registry.go +++ b/internal/commands/registry.go @@ -126,6 +126,13 @@ func (r *Registry) checkPermission(ctx *Context, required Permission) error { if ctx.Username == ctx.Config.MainAdmin { return nil } + teams, err := ctx.Store.GetTeamsForUser(ctx.UserID) + if err != nil { + return fmt.Errorf("Error checking membership.") + } + if len(teams) == 0 { + return fmt.Errorf("You are not a member of any team.") + } return nil default: return nil diff --git a/internal/commands/standup.go b/internal/commands/standup.go index 530218a..8ff60d1 100644 --- a/internal/commands/standup.go +++ b/internal/commands/standup.go @@ -12,6 +12,60 @@ func RegisterStandupCommands(r *Registry) { r.Register("submit", handleSubmit, PermissionMember) r.Register("status", handleStatus, PermissionMember) r.Register("report", handleReport, PermissionAny) + r.Register("cancel", handleCancel, PermissionMember) + r.Register("list", handleList, PermissionAny) +} + +func resolveSubmitTeam(ctx *Context, teams []*store.Team) (*store.Team, string, error) { + if len(teams) == 1 { + return teams[0], "", nil + } + + for i, a := range ctx.Args { + if (a == "--team" || a == "-t") && i+1 < len(ctx.Args) { + name := ctx.Args[i+1] + for _, t := range teams { + if strings.EqualFold(t.Name, name) { + return t, "", nil + } + } + return nil, "", fmt.Errorf("You are not a member of team %q.", name) + } + } + + var names []string + for _, t := range teams { + names = append(names, t.Name) + } + return nil, "", fmt.Errorf( + "You are in multiple teams. Use `--team \"\"`: %s", + strings.Join(names, ", "), + ) +} + +func activeOrNewSession(ctx *Context, teamID, teamName string) (string, error) { + date := time.Now().Format("2006-01-02") + + session, err := ctx.Store.GetActiveSession(teamID, date) + if err == nil { + submitted, _ := ctx.Store.HasSubmitted(session.ID, ctx.UserID) + if submitted { + return "", fmt.Errorf("You have already submitted your standup for %s today.", teamName) + } + return session.ID, nil + } + + sessionID := fmt.Sprintf("sess-%d-%s", time.Now().UnixMilli(), teamID) + session = &store.StandupSession{ + ID: sessionID, + TeamID: teamID, + Date: date, + Status: "open", + } + if err := ctx.Store.CreateSession(session); err != nil { + return "", fmt.Errorf("Failed to create session: %v", err) + } + return sessionID, nil } func handleSubmit(ctx *Context) error { @@ -25,26 +79,20 @@ func handleSubmit(ctx *Context) error { "You are not a member of any team.") } + team, _, err := resolveSubmitTeam(ctx, teams) + if err != nil { + return send(ctx.Messenger, ctx.RoomID, err.Error()) + } + dmRoomID, err := ensureDMRoom(ctx) if err != nil { return send(ctx.Messenger, ctx.RoomID, fmt.Sprintf("Could not start DM: %v", err)) } - team := teams[0] - date := time.Now().Format("2006-01-02") - - sessionID := fmt.Sprintf("sess-%d-%s", time.Now().UnixMilli(), team.ID) - - session := &store.StandupSession{ - ID: sessionID, - TeamID: team.ID, - Date: date, - Status: "open", - } - if err := ctx.Store.CreateSession(session); err != nil { - return send(ctx.Messenger, ctx.RoomID, - fmt.Sprintf("Failed to create session: %v", err)) + sessionID, err := activeOrNewSession(ctx, team.ID, team.Name) + if err != nil { + return send(ctx.Messenger, ctx.RoomID, err.Error()) } questions := strings.Split(team.Questions, "|") @@ -68,8 +116,12 @@ func handleStatus(ctx *Context) error { return send(ctx.Messenger, ctx.RoomID, "You are not a member of any team.") } + team, _, err := resolveSubmitTeam(ctx, teams) + if err != nil { + return send(ctx.Messenger, ctx.RoomID, err.Error()) + } + date := time.Now().Format("2006-01-02") - team := teams[0] sessID := findSessionID(ctx, team.ID, date) submitted, total, err := ctx.Store.GetSessionStatus(team.ID, date) @@ -90,6 +142,63 @@ func handleStatus(ctx *Context) error { team.Name, statusLine, submitted, total)) } +func handleCancel(ctx *Context) error { + conv, ok := ctx.ConvState.GetConversation(ctx.UserID) + if !ok { + return send(ctx.Messenger, ctx.RoomID, + "You don't have an active standup submission.") + } + + cancelTo := conv.RoomID + if !ctx.IsDM { + cancelTo = ctx.RoomID + } + + partial, err := ctx.ConvState.Cancel(ctx.UserID) + if err != nil { + return send(ctx.Messenger, cancelTo, + "You don't have an active standup submission.") + } + + if partial == "" { + partial = "No answers recorded." + } + + return send(ctx.Messenger, cancelTo, + fmt.Sprintf("❌ *Standup cancelled.* Your partial answers:\n%s", partial)) +} + +func handleList(ctx *Context) error { + var teams []*store.Team + var err error + + if ctx.Username == ctx.Config.MainAdmin { + teams, err = ctx.Store.ListTeams() + } else { + teams, err = ctx.Store.GetTeamsForUser(ctx.UserID) + } + if err != nil { + return send(ctx.Messenger, ctx.RoomID, + "Error looking up teams.") + } + if len(teams) == 0 { + return send(ctx.Messenger, ctx.RoomID, "No teams found.") + } + + var lines []string + for _, t := range teams { + isLead, _ := ctx.Store.IsTeamLead(t.ID, ctx.UserID) + role := "" + if isLead { + role = " (lead)" + } + lines = append(lines, fmt.Sprintf(" • **%s**%s", t.Name, role)) + } + + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("Teams:\n%s", strings.Join(lines, "\n"))) +} + func handleReport(ctx *Context) error { team, err := resolveTeam(ctx) if err != nil { diff --git a/internal/commands/standup_test.go b/internal/commands/standup_test.go index 999b68d..445323b 100644 --- a/internal/commands/standup_test.go +++ b/internal/commands/standup_test.go @@ -201,6 +201,67 @@ func TestStandupReportNonLeadDenied(t *testing.T) { } } +func TestStandupCancelNoConversation(t *testing.T) { + h := newStandupHarness(t) + + h.dispatch("/standup cancel") + + msg := strings.Join(h.msgr.sent, " ") + if !contains(msg, "don't have") && !contains(msg, "active") { + t.Logf("Cancel message: %s", msg) + } +} + +func TestStandupCancelActive(t *testing.T) { + h := newStandupHarness(t) + + h.dispatch("/standup submit") + + h.dispatch("/standup cancel") + + msg := strings.Join(h.msgr.sent, " ") + if !contains(msg, "cancelled") { + t.Errorf("expected cancellation message, got: %s", msg) + } + + _, ok := h.convMgr.GetConversation("alice-id") + if ok { + t.Error("conversation should be cancelled") + } +} + +func TestStandupListTeams(t *testing.T) { + h := newStandupHarness(t) + + h.dispatch("/standup list") + + msg := strings.Join(h.msgr.sent, " ") + if !contains(msg, "Eng") { + t.Errorf("expected team name in list, got: %s", msg) + } +} + +func TestStandupSubmitDuplicateSession(t *testing.T) { + h := newStandupHarness(t) + + now := time.Now().Format("2006-01-02") + _ = h.s.CreateSession(&store.StandupSession{ + ID: "sess-existing", TeamID: "t1", Date: now, Status: "open", + }) + _ = h.s.SubmitResponse(&store.StandupResponse{ + ID: "resp-existing", SessionID: "sess-existing", UserID: "alice-id", + Username: "alice", Answers: "Fine|Coding|None", + SubmittedAt: time.Now(), + }) + + h.dispatch("/standup submit") + + msg := strings.Join(h.msgr.sent, " ") + if !contains(msg, "already submitted") { + t.Errorf("expected duplicate rejection, got: %s", msg) + } +} + func TestStandupConversationFlow(t *testing.T) { h := newStandupHarness(t) From 0d1735e7f7119fb8fd118afa12dc7c724a5b25e1 Mon Sep 17 00:00:00 2001 From: sunba91-su Date: Mon, 8 Jun 2026 18:04:27 +0330 Subject: [PATCH 6/6] fix: use CR_PAT secret instead of GITHUB_TOKEN for ghcr.io auth Default GITHUB_TOKEN lacks permission to create org packages. Switching to a Personal Access Token (CR_PAT) with write:packages scope. --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02f67cf..9e3ca68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,7 @@ jobs: with: registry: ghcr.io username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + password: ${{ secrets.CR_PAT }} - name: Build and push uses: docker/build-push-action@v6 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32a46a9..ec6c09e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: with: registry: ghcr.io username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + password: ${{ secrets.CR_PAT }} - name: Build and push Docker image uses: docker/build-push-action@v6