Repository: remotemobprogramming/mob
Pull request #321 (2022-09-30 12:58:50)

Better timer
I added the functionality that local timers will be aborted if a new timer is set or if the commands mob next or mob done were executed.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6f56de7..a559ba2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 # 4.0.0
 - **NEW** Feature: `mob reset` doesn't reset the mob branch anymore. It now warns you that it deletes the mob branch for everyone and if you want to continue do `mob reset --delete-remote-wip-branch`.
+- **NEW** Feature: `mob timer`, `mob break`, `mob start`, `mob next` and `mob done` will stop already running local timers.
 - Feature: `mob start --create` will create the remote branch and start the mob session on that branch.
 - Feature: mob.sh now also works with tools like `git-repo` which symlink the `.git` folder
 
diff --git a/mob.go b/mob.go
index 0eda572..3821b97 100644
--- a/mob.go
+++ b/mob.go
@@ -17,6 +17,7 @@ import (
 	"os"
 	"os/exec"
 	"reflect"
+	"regexp"
 	"runtime"
 	"strconv"
 	"strings"
@@ -442,7 +443,8 @@ func startTimer(timerInMinutes string, configuration config.Configuration) {
 	}
 
 	if startLocalTimer {
-		err := executeCommandsInBackgroundProcess(getSleepCommand(timeoutInSeconds), getVoiceCommand(configuration.VoiceMessage, configuration.VoiceCommand), getNotifyCommand(configuration.NotifyMessage, configuration.NotifyCommand))
+		abortRunningTimers()
+		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))
@@ -454,6 +456,94 @@ func startTimer(timerInMinutes string, configuration config.Configuration) {
 	say.Info("It's now " + currentTime() + ". " + fmt.Sprintf("%d min timer ends at approx. %s", timeoutInMinutes, timeOfTimeout) + ". Happy collaborating! :)")
 }
 
+func abortRunningTimers() {
+	processIds := findMobTimerProcessIds()
+	for _, processId := range processIds {
+		killRunningProcess(processId)
+	}
+}
+
+func killRunningProcess(processId string) {
+	var err error
+	switch runtime.GOOS {
+	case "darwin", "linux":
+		err = killRunningProcessLinuxAndDarwin(processId)
+		break
+	case "windows":
+		err = killRunningProcessWindows(processId)
+	}
+	if err != nil {
+		say.Error(fmt.Sprintf("old timer couldn't be aborted on your system (%s)", runtime.GOOS))
+		say.Error(err.Error())
+	}
+	say.Debug("Killed mob timer with PID " + processId)
+}
+
+func killRunningProcessLinuxAndDarwin(processId string) error {
+	_, _, err := runCommandSilent("kill", processId)
+	return err
+}
+
+func killRunningProcessWindows(processId string) error {
+	_, _, err := runCommandSilent("powershell", "-command", "Stop-Process", "-Id", processId)
+	return err
+}
+
+func findMobTimerProcessIds() []string {
+	say.Debug("find all mob timer processes")
+	switch runtime.GOOS {
+	case "darwin", "linux":
+		return findMobTimerProcessIdsLinuxAndDarwin()
+	case "windows":
+		return findMobTimerProcessIdsWindows()
+	}
+	return []string{}
+}
+
+func findMobTimerProcessIdsWindows() []string {
+	_, output, err := runCommandSilent("powershell", "-command", "Get-WmiObject", "Win32_Process", "-Filter", "\"commandLine LIKE '%mobTimer%'\"", "|", "Select-Object", "-Property", "ProcessId,CommandLine", "|", "Out-String", "-Width", "10000")
+	if err != nil {
+		say.Error(fmt.Sprintf("could not find processes on your system (%s)", runtime.GOOS))
+		say.Error(err.Error())
+	}
+	lines := strings.Split(output, "\r\n")
+	var filteredLines []string
+	for _, line := range lines {
+		if line != "" {
+			filteredLines = append(filteredLines, line)
+		}
+	}
+	processInfos := filteredLines[2:]
+	var timerProcessIds []string
+	for _, processInfo := range processInfos {
+		if strings.Contains(processInfo, "echo \"mobTimer\"") {
+			timerProcessIds = append(timerProcessIds, strings.Split(strings.TrimSpace(processInfo), " ")[0])
+		}
+	}
+	return timerProcessIds
+}
+
+func findMobTimerProcessIdsLinuxAndDarwin() []string {
+	_, output, err := runCommandSilent("ps", "-f")
+	lines := strings.Split(output, "\n")
+	if err != nil {
+		say.Error(fmt.Sprintf("could not find processes on your system (%s)", runtime.GOOS))
+		say.Error(err.Error())
+	}
+	var processIds []string
+	space := regexp.MustCompile(`\s+`)
+	for _, line := range lines {
+		if strings.Contains(line, "echo \"mobTimer\"") {
+			line = space.ReplaceAllLiteralString(line, " ")
+			line = strings.TrimSpace(line)
+			processId := strings.Split(line, " ")[1]
+			processIds = append(processIds, processId)
+			say.Debug("Found mob timer with PID " + processId)
+		}
+	}
+	return processIds
+}
+
 func getMobTimerRoom(configuration config.Configuration) string {
 	if !isGit() {
 		say.Debug("timer not in git repository, using MOB_TIMER_ROOM for room name")
@@ -507,7 +597,8 @@ func startBreakTimer(timerInMinutes string, configuration config.Configuration)
 	}
 
 	if startLocalTimer {
-		err := executeCommandsInBackgroundProcess(getSleepCommand(timeoutInSeconds), getVoiceCommand("mob start", configuration.VoiceCommand), getNotifyCommand("mob start", configuration.NotifyCommand))
+		abortRunningTimers()
+		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))
@@ -878,6 +969,7 @@ func next(configuration config.Configuration) {
 		gitWithoutEmptyStrings("push", gitHooksOption(configuration), configuration.RemoteName, currentWipBranch.Name)
 	}
 	showNext(configuration)
+	abortRunningTimers()
 
 	if !configuration.NextStay {
 		git("checkout", currentBaseBranch.Name)
@@ -1041,6 +1133,7 @@ func done(configuration config.Configuration) {
 		git("branch", "-D", wipBranch.Name)
 		say.Info("someone else already ended your session")
 	}
+	abortRunningTimers()
 }
 
 func gitDir() string {


Test patch:
diff --git a/mob_test.go b/mob_test.go
index b4058b8..ebda8bb 100644
--- a/mob_test.go
+++ b/mob_test.go
@@ -4,6 +4,7 @@ import (
 	"fmt"
 	config "github.com/remotemobprogramming/mob/v4/configuration"
 	"github.com/remotemobprogramming/mob/v4/say"
+	"github.com/remotemobprogramming/mob/v4/test"
 	"io/ioutil"
 	"os"
 	"path/filepath"
@@ -13,6 +14,7 @@ import (
 	"strconv"
 	"strings"
 	"testing"
+	"time"
 )
 
 var (
@@ -1505,6 +1507,59 @@ func TestHelpRequested(t *testing.T) {
 	equals(t, true, helpRequested([]string{"s", "10", "-h"}))
 }
 
+func TestAbortTimerIfNewTimerIsStarted(t *testing.T) {
+	_, configuration := setup(t)
+	startTimer("10", configuration)
+	assertSingleTimerProcess(t)
+
+	startTimer("10", configuration)
+
+	time.Sleep(time.Millisecond)
+	assertSingleTimerProcess(t)
+	abortRunningTimers()
+}
+
+func assertSingleTimerProcess(t *testing.T) {
+	test.Await(t, func() bool { return 1 == len(findMobTimerProcessIds()) })
+}
+
+func assertNoTimerProcess(t *testing.T) {
+	test.Await(t, func() bool { return 0 == len(findMobTimerProcessIds()) })
+}
+
+func TestAbortBreakTimerIfNewBreakTimerIsStarted(t *testing.T) {
+	_, configuration := setup(t)
+	startBreakTimer("10", configuration)
+	assertSingleTimerProcess(t)
+
+	startBreakTimer("10", configuration)
+
+	assertSingleTimerProcess(t)
+	abortRunningTimers()
+}
+
+func TestAbortTimerIfMobNext(t *testing.T) {
+	_, configuration := setup(t)
+	start(configuration)
+	startTimer("10", configuration)
+	assertSingleTimerProcess(t)
+
+	next(configuration)
+
+	assertNoTimerProcess(t)
+}
+
+func TestAbortTimerIfMobDone(t *testing.T) {
+	_, configuration := setup(t)
+	start(configuration)
+	startTimer("10", configuration)
+	assertSingleTimerProcess(t)
+
+	done(configuration)
+
+	assertNoTimerProcess(t)
+}
+
 func gitStatus() GitStatus {
 	shortStatus := silentgit("status", "--short")
 	statusLines := strings.Split(shortStatus, "\n")
@@ -1528,6 +1583,7 @@ func setup(t *testing.T) (output *string, configuration config.Configuration) {
 	equals(t, []string{"master"}, gitBranches())
 	equals(t, []string{"origin/master"}, gitRemoteBranches())
 	assertNoMobSessionBranches(t, configuration, "mob-session")
+	abortRunningTimers()
 	return output, configuration
 }
 
diff --git a/test/test.go b/test/test.go
index f4f6c5a..93f2912 100644
--- a/test/test.go
+++ b/test/test.go
@@ -10,12 +10,18 @@ import (
 	"runtime/debug"
 	"strings"
 	"testing"
+	"time"
 )
 
 var (
 	workingDir string
 )
 
+const (
+	AWAIT_DEFAULT_POLL_INTERVAL = 100 * time.Millisecond
+	AWAIT_DEFAULT_AT_MOST       = 5 * time.Second
+)
+
 func Equals(t *testing.T, exp, act interface{}) {
 	if !reflect.DeepEqual(exp, act) {
 		t.Log(string(debug.Stack()))
@@ -29,6 +35,12 @@ func failWithFailure(t *testing.T, exp interface{}, act interface{}) {
 	t.FailNow()
 }
 
+func failWithFailureMessage(t *testing.T, message string) {
+	_, file, line, _ := runtime.Caller(1)
+	fmt.Printf("\033[31m%s:%d:\n\n\t%s", filepath.Base(file), line, message)
+	t.FailNow()
+}
+
 func CreateFile(t *testing.T, filename string, content string) (pathToFile string) {
 	contentAsBytes := []byte(content)
 	pathToFile = workingDir + "/" + filename
@@ -65,3 +77,38 @@ func AssertOutputNotContains(t *testing.T, output *string, notContains string) {
 		failWithFailure(t, "output not contains "+notContains, output)
 	}
 }
+
+func Await(t *testing.T, until func() bool) {
+	AwaitBlocking(t, AWAIT_DEFAULT_POLL_INTERVAL, AWAIT_DEFAULT_AT_MOST, until)
+}
+
+func AwaitBlocking(t *testing.T, pollInterval time.Duration, atMost time.Duration, until func() bool) {
+	if pollInterval <= 0 {
+		failWithFailureMessage(t, fmt.Sprintf("PollInterval cannot be 0 or below, got: %d", pollInterval))
+		return
+	}
+	if atMost <= 0 {
+		failWithFailureMessage(t, fmt.Sprintf("AtMost timeout cannot be 0 or below, got: %d", atMost))
+		return
+	}
+	if pollInterval > atMost {
+		failWithFailureMessage(t, fmt.Sprintf("PollInterval must be smaller than atMost timeout, got: pollInterval=%d, atMost=%d", pollInterval, atMost))
+		return
+	}
+	startTime := time.Now()
+	timeLeft := atMost
+
+	for {
+		if until() {
+			return
+		} else {
+			timeLeft = atMost - time.Now().Sub(startTime)
+			if timeLeft <= 0 {
+				stackTrace := string(debug.Stack())
+				failWithFailureMessage(t, fmt.Sprintf("Timeout Error: %s", stackTrace))
+				return
+			}
+		}
+		time.Sleep(pollInterval)
+	}
+}