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 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)