Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
60359c4
Abstract local timer functionality behind LocalTimer interface
claude Feb 19, 2026
d62c550
Pass Configuration into LocalTimer interface instead of individual fi…
claude Feb 19, 2026
41e8981
Rename timeoutInMinutes to minutes in LocalTimer interface
claude Feb 19, 2026
abef7e0
Rename LocalTimer interface to Timer
claude Feb 19, 2026
08e4794
Implement WebTimer and introduce getTimers() as central timer selection
claude Feb 19, 2026
26cd6ec
Extract timer code into its own timer/ package
claude Feb 19, 2026
864f2b6
Refactor Timer interface: constructor injection and IsActive()
claude Feb 20, 2026
03692a8
WebTimer reads room and timerUser from configuration
claude Feb 20, 2026
9a8f74b
Extract timer implementations into self-registering sub-packages
claude Feb 20, 2026
cb6878c
Remove IsActive() from Timer interface - factories return nil instead
hollesse Feb 20, 2026
7df582e
Move getUserForMobTimer to WebTimer and use git package
hollesse Feb 20, 2026
66c906c
Move timer room logic to WebTimer constructor and enrich config with …
hollesse Feb 20, 2026
c14e53c
Replace registry/init() with direct instantiation in timer/timer.go
claude Apr 20, 2026
da5f2c5
Make getTimers private since it is only used internally
claude Apr 21, 2026
1d11296
Rename getTimers to getActiveTimers and log active timers on debug
claude Apr 21, 2026
bb90ab4
Simplify getActiveTimer to return a single Timer instead of a slice
claude Apr 21, 2026
9f0700d
Log all active timers and the selected one in getActiveTimer
claude Apr 21, 2026
eb808db
Remove unnecessary timerpkg alias, import timer package by its own name
claude Apr 21, 2026
3656b77
Add tests for timer, localtimer, and webtimer packages
claude Apr 21, 2026
8ced134
Separate timer construction from selection for testability with mocks
claude Apr 21, 2026
3a5e206
Verify minutes are passed correctly to timer via mock in tests
claude Apr 21, 2026
ddc5699
Use project test library in localtimer tests
claude Apr 21, 2026
a575676
Move Moo to localtimer package and make VoiceCommand/ExecuteCommandsI…
claude Apr 21, 2026
f60bb89
Use project test library in timer tests
claude Apr 21, 2026
8f21627
Use project test library in webtimer tests
claude Apr 21, 2026
1c4e3d6
Add background process and info log tests to localtimer
claude Apr 21, 2026
eaebac2
Fix side-effect files in localtimer tests by appending '; true' to vo…
claude Apr 21, 2026
9753c45
Assert 'Starting command' debug log in StartTimer and StartBreakTimer…
claude Apr 21, 2026
0b76909
Assert info and error logs in timer tests
claude Apr 21, 2026
49ee484
Extract newCapturingServer helper and assert PUT method in break time…
claude Apr 21, 2026
1eda632
Also verify notify command is executed in StartTimer and StartBreakTi…
claude Apr 21, 2026
edcffdb
Extract AwaitFileCreated into test package
claude Apr 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 20 additions & 32 deletions mob.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/remotemobprogramming/mob/v5/help"
"github.com/remotemobprogramming/mob/v5/open"
"github.com/remotemobprogramming/mob/v5/say"
"github.com/remotemobprogramming/mob/v5/timer/localtimer"
"github.com/remotemobprogramming/mob/v5/workdir"
)

Expand Down Expand Up @@ -329,7 +330,7 @@ func execute(command string, parameter []string, configuration config.Configurat
help.Help(configuration)
}
case "moo":
moo(configuration)
localtimer.Moo(configuration)
case "sw", "squash-wip":
if len(parameter) > 1 && parameter[0] == "--git-editor" {
squashWipGitEditor(parameter[1], configuration)
Expand Down Expand Up @@ -434,6 +435,24 @@ func determineBranches(currentBranch Branch, localBranches []string, configurati
return
}

func enrichConfigurationWithBranchQualifier(configuration config.Configuration) config.Configuration {
if !isGit() {
return configuration
}

if configuration.WipBranchQualifier == "" {
currentBranch := gitCurrentBranch()
currentBaseBranch, _ := determineBranches(currentBranch, gitBranches(), configuration)

if currentBranch.IsWipBranch(configuration) {
wipBranchWithoutWipPrefix := currentBranch.removeWipPrefix(configuration).Name
configuration.WipBranchQualifier = removePrefix(removePrefix(wipBranchWithoutWipPrefix, currentBaseBranch.Name), configuration.WipBranchQualifierSeparator)
}
}

return configuration
}

func injectCommandWithMessage(command string, message string) string {
placeHolders := strings.Count(command, "%s")
if placeHolders > 1 {
Expand All @@ -446,41 +465,10 @@ func injectCommandWithMessage(command string, message string) string {
return fmt.Sprintf(command, message)
}

func executeCommandsInBackgroundProcess(commands ...string) (err error) {
cmds := make([]string, 0)
for _, c := range commands {
if len(c) > 0 {
cmds = append(cmds, c)
}
}
say.Debug(fmt.Sprintf("Operating System %s", runtime.GOOS))
switch runtime.GOOS {
case "windows":
_, err = startCommand("powershell", "-command", fmt.Sprintf("start-process powershell -NoNewWindow -ArgumentList '-command \"%s\"'", strings.Join(cmds, ";")))
case "darwin", "linux":
_, err = startCommand("sh", "-c", fmt.Sprintf("(%s) &", strings.Join(cmds, ";")))
default:
say.Warning(fmt.Sprintf("Cannot execute background commands on your os: %s", runtime.GOOS))
}
return err
}

func currentTime() string {
return time.Now().Format("15:04")
}

func moo(configuration config.Configuration) {
voiceMessage := "moo"
err := executeCommandsInBackgroundProcess(getVoiceCommand(voiceMessage, configuration.VoiceCommand))

if err != nil {
say.Warning(fmt.Sprintf("can't run voice command on your system (%s)", runtime.GOOS))
say.Warning(err.Error())
return
}

say.Info(voiceMessage)
}

func reset(configuration config.Configuration) {
if configuration.ResetDeleteRemoteWipBranch {
Expand Down
8 changes: 8 additions & 0 deletions test/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ func AssertOutputNotContains(t *testing.T, output *string, notContains string) {
}
}

func AwaitFileCreated(t *testing.T, path string) {
t.Helper()
Await(t, func() bool {
_, err := os.Stat(path)
return err == nil
}, "file created: "+path)
}

func Await(t *testing.T, until func() bool, awaitedState string) {
AwaitBlocking(t, AWAIT_DEFAULT_POLL_INTERVAL, AWAIT_DEFAULT_AT_MOST, until, awaitedState)
}
Expand Down
174 changes: 5 additions & 169 deletions timer.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,9 @@
package main

import (
"encoding/json"
"errors"
"fmt"
"runtime"
"strconv"
"time"

config "github.com/remotemobprogramming/mob/v5/configuration"
"github.com/remotemobprogramming/mob/v5/exit"
"github.com/remotemobprogramming/mob/v5/httpclient"
"github.com/remotemobprogramming/mob/v5/say"
"github.com/remotemobprogramming/mob/v5/timer"
)

func StartTimer(timerInMinutes string, configuration config.Configuration) {
Expand All @@ -21,71 +13,8 @@ func StartTimer(timerInMinutes string, configuration config.Configuration) {
}

func startTimer(timerInMinutes string, configuration config.Configuration) error {
err, timeoutInMinutes := toMinutes(timerInMinutes)
if err != nil {
return err
}

timeoutInSeconds := timeoutInMinutes * 60
timeOfTimeout := time.Now().Add(time.Minute * time.Duration(timeoutInMinutes)).Format("15:04")
say.Debug(fmt.Sprintf("Starting timer at %s for %d minutes = %d seconds (parsed from user input %s)", timeOfTimeout, timeoutInMinutes, timeoutInSeconds, timerInMinutes))

room := getMobTimerRoom(configuration)
startRemoteTimer := room != ""
startLocalTimer := configuration.TimerLocal

if !startRemoteTimer && !startLocalTimer {
say.Error("No timer configured, not starting timer")
exit.Exit(1)
}

if startRemoteTimer {
timerUser := getUserForMobTimer(configuration.TimerUser)
err := httpPutTimer(timeoutInMinutes, room, timerUser, configuration.TimerUrl, configuration.TimerInsecure)
if err != nil {
say.Error("remote timer couldn't be started")
say.Error(err.Error())
exit.Exit(1)
}
}

if startLocalTimer {
err := executeCommandsInBackgroundProcess(getSleepCommand(timeoutInSeconds), getVoiceCommand(configuration.VoiceMessage, configuration.VoiceCommand), getNotifyCommand(configuration.NotifyMessage, configuration.NotifyCommand), "echo \"mobTimer\"")

if err != nil {
say.Error(fmt.Sprintf("timer couldn't be started on your system (%s)", runtime.GOOS))
say.Error(err.Error())
exit.Exit(1)
}
}

say.Info("It's now " + currentTime() + ". " + fmt.Sprintf("%d min timer ends at approx. %s", timeoutInMinutes, timeOfTimeout) + ". Happy collaborating! :)")
return nil
}

func getMobTimerRoom(configuration config.Configuration) string {
if !isGit() {
say.Debug("timer not in git repository, using MOB_TIMER_ROOM for room name")
return configuration.TimerRoom
}

currentWipBranchQualifier := configuration.WipBranchQualifier
if currentWipBranchQualifier == "" {
currentBranch := gitCurrentBranch()
currentBaseBranch, _ := determineBranches(currentBranch, gitBranches(), configuration)

if currentBranch.IsWipBranch(configuration) {
wipBranchWithoutWipPrefix := currentBranch.removeWipPrefix(configuration).Name
currentWipBranchQualifier = removePrefix(removePrefix(wipBranchWithoutWipPrefix, currentBaseBranch.Name), configuration.WipBranchQualifierSeparator)
}
}

if configuration.TimerRoomUseWipBranchQualifier && currentWipBranchQualifier != "" {
say.Info("Using wip branch qualifier for room name")
return currentWipBranchQualifier
}

return configuration.TimerRoom
configuration = enrichConfigurationWithBranchQualifier(configuration)
return timer.RunTimer(timerInMinutes, configuration)
}

func StartBreakTimer(timerInMinutes string, configuration config.Configuration) {
Expand All @@ -95,99 +24,6 @@ func StartBreakTimer(timerInMinutes string, configuration config.Configuration)
}

func startBreakTimer(timerInMinutes string, configuration config.Configuration) error {
err, timeoutInMinutes := toMinutes(timerInMinutes)
if err != nil {
return err
}

timeoutInSeconds := timeoutInMinutes * 60
timeOfTimeout := time.Now().Add(time.Minute * time.Duration(timeoutInMinutes)).Format("15:04")
say.Debug(fmt.Sprintf("Starting break timer at %s for %d minutes = %d seconds (parsed from user input %s)", timeOfTimeout, timeoutInMinutes, timeoutInSeconds, timerInMinutes))

room := getMobTimerRoom(configuration)
startRemoteTimer := room != ""
startLocalTimer := configuration.TimerLocal

if !startRemoteTimer && !startLocalTimer {
say.Error("No break timer configured, not starting break timer")
exit.Exit(1)
}

if startRemoteTimer {
timerUser := getUserForMobTimer(configuration.TimerUser)
err := httpPutBreakTimer(timeoutInMinutes, room, timerUser, configuration.TimerUrl, configuration.TimerInsecure)

if err != nil {
say.Error("remote break timer couldn't be started")
say.Error(err.Error())
exit.Exit(1)
}
}

if startLocalTimer {
err := executeCommandsInBackgroundProcess(getSleepCommand(timeoutInSeconds), getVoiceCommand("mob start", configuration.VoiceCommand), getNotifyCommand("mob start", configuration.NotifyCommand), "echo \"mobTimer\"")

if err != nil {
say.Error(fmt.Sprintf("break timer couldn't be started on your system (%s)", runtime.GOOS))
say.Error(err.Error())
exit.Exit(1)
}
}

say.Info("It's now " + currentTime() + ". " + fmt.Sprintf("%d min break timer ends at approx. %s", timeoutInMinutes, timeOfTimeout) + ". So take a break now! :)")
return nil
}

func getUserForMobTimer(userOverride string) string {
if userOverride == "" {
return gitUserName()
}
return userOverride
}

func toMinutes(timerInMinutes string) (error, int) {
timeoutInMinutes, err := strconv.Atoi(timerInMinutes)
if err != nil || timeoutInMinutes < 1 {
say.Error(fmt.Sprintf("The parameter must be an integer number greater then zero"))
return errors.New("The parameter must be an integer number greater then zero"), 0
}
return nil, timeoutInMinutes
}

func httpPutTimer(timeoutInMinutes int, room string, user string, timerService string, disableSSLVerification bool) error {
putBody, _ := json.Marshal(map[string]interface{}{
"timer": timeoutInMinutes,
"user": user,
})
client := httpclient.CreateHttpClient(disableSSLVerification)
_, err := client.SendRequest(putBody, "PUT", timerService+room)
return err
}

func httpPutBreakTimer(timeoutInMinutes int, room string, user string, timerService string, disableSSLVerification bool) error {
putBody, _ := json.Marshal(map[string]interface{}{
"breaktimer": timeoutInMinutes,
"user": user,
})
client := httpclient.CreateHttpClient(disableSSLVerification)
_, err := client.SendRequest(putBody, "PUT", timerService+room)
return err
}

func getSleepCommand(timeoutInSeconds int) string {
return fmt.Sprintf("sleep %d", timeoutInSeconds)
}

func getVoiceCommand(message string, voiceCommand string) string {
if len(voiceCommand) == 0 {
return ""
}
return injectCommandWithMessage(voiceCommand, message)
}

func getNotifyCommand(message string, notifyCommand string) string {
if len(notifyCommand) == 0 {
return ""
}
return injectCommandWithMessage(notifyCommand, message)
configuration = enrichConfigurationWithBranchQualifier(configuration)
return timer.RunBreakTimer(timerInMinutes, configuration)
}
Loading
Loading