diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f56d1835..5566f4a3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,12 +28,14 @@ jobs: - name: Build binaries run: | mkdir -p dist + VERSION=${{ steps.version.outputs.VERSION }} + LDFLAGS="-s -w -X main.version=${VERSION}" # Build ty CLI for all platforms - GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -o dist/ty-darwin-amd64 ./cmd/task - GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o dist/ty-darwin-arm64 ./cmd/task - GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o dist/ty-linux-amd64 ./cmd/task - GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o dist/ty-linux-arm64 ./cmd/task + GOOS=darwin GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o dist/ty-darwin-amd64 ./cmd/task + GOOS=darwin GOARCH=arm64 go build -ldflags="${LDFLAGS}" -o dist/ty-darwin-arm64 ./cmd/task + GOOS=linux GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o dist/ty-linux-amd64 ./cmd/task + GOOS=linux GOARCH=arm64 go build -ldflags="${LDFLAGS}" -o dist/ty-linux-arm64 ./cmd/task # Build taskd daemon for Linux only GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o dist/taskd-linux-amd64 ./cmd/taskd diff --git a/cmd/task/main.go b/cmd/task/main.go index a303bede..1463a9ca 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -127,8 +127,8 @@ Examples: cwd, _ := os.Getwd() exec := executor.New(database, config.New(database)) - model := ui.NewAppModel(database, exec, cwd) - + model := ui.NewAppModel(database, exec, cwd, version) + // Load tasks synchronously to ensure model is populated tasks, err := database.ListTasks(db.ListTasksOptions{ IncludeClosed: true, @@ -2994,7 +2994,7 @@ func runLocal(dangerousMode bool, debugStatePath string) error { cwd, _ := os.Getwd() // Create and run TUI - model := ui.NewAppModel(database, exec, cwd) + model := ui.NewAppModel(database, exec, cwd, version) if debugStatePath != "" { model.SetDebugStatePath(debugStatePath) } diff --git a/internal/github/version.go b/internal/github/version.go new file mode 100644 index 00000000..d063c4f4 --- /dev/null +++ b/internal/github/version.go @@ -0,0 +1,116 @@ +// Package github provides GitHub integration. +package github + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +// ghReleaseResponse is the JSON response from GitHub releases API. +type ghReleaseResponse struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` +} + +// LatestRelease holds information about the latest GitHub release. +type LatestRelease struct { + Version string // e.g. "v0.2.0" + URL string // release page URL +} + +const ( + releaseRepo = "bborn/taskyou" + releaseTimeout = 5 * time.Second +) + +// FetchLatestRelease queries the GitHub API for the latest release. +// Returns nil if the request fails or no release exists. +func FetchLatestRelease() *LatestRelease { + ctx, cancel := context.WithTimeout(context.Background(), releaseTimeout) + defer cancel() + + url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", releaseRepo) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil + } + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil + } + + var release ghReleaseResponse + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil + } + + if release.TagName == "" { + return nil + } + + return &LatestRelease{ + Version: release.TagName, + URL: release.HTMLURL, + } +} + +// IsNewerVersion returns true if latest is a newer version than current. +// Both should be semver strings, optionally prefixed with "v". +func IsNewerVersion(current, latest string) bool { + if current == "" || latest == "" || current == "dev" { + return false + } + + cur := parseVersion(current) + lat := parseVersion(latest) + if cur == nil || lat == nil { + return false + } + + if lat[0] != cur[0] { + return lat[0] > cur[0] + } + if lat[1] != cur[1] { + return lat[1] > cur[1] + } + return lat[2] > cur[2] +} + +// parseVersion parses a "v1.2.3" or "1.2.3" string into [major, minor, patch]. +// Returns nil on failure. +func parseVersion(v string) []int { + v = strings.TrimPrefix(v, "v") + // Strip pre-release suffixes (e.g. "-rc1", "-beta") + if idx := strings.IndexByte(v, '-'); idx >= 0 { + v = v[:idx] + } + + parts := strings.Split(v, ".") + if len(parts) != 3 { + return nil + } + + result := make([]int, 3) + for i, p := range parts { + n := 0 + for _, c := range p { + if c < '0' || c > '9' { + return nil + } + n = n*10 + int(c-'0') + } + result[i] = n + } + return result +} diff --git a/internal/github/version_test.go b/internal/github/version_test.go new file mode 100644 index 00000000..0e3fa262 --- /dev/null +++ b/internal/github/version_test.go @@ -0,0 +1,64 @@ +package github + +import "testing" + +func TestIsNewerVersion(t *testing.T) { + tests := []struct { + name string + current string + latest string + want bool + }{ + {"newer patch", "v0.1.0", "v0.1.1", true}, + {"newer minor", "v0.1.0", "v0.2.0", true}, + {"newer major", "v0.1.0", "v1.0.0", true}, + {"same version", "v0.1.0", "v0.1.0", false}, + {"older version", "v0.2.0", "v0.1.0", false}, + {"no v prefix", "0.1.0", "0.2.0", true}, + {"mixed prefix", "v0.1.0", "0.2.0", true}, + {"dev version", "dev", "v0.1.0", false}, + {"empty current", "", "v0.1.0", false}, + {"empty latest", "v0.1.0", "", false}, + {"pre-release latest", "v0.1.0", "v0.2.0-rc1", true}, + {"pre-release current", "v0.1.0-beta", "v0.1.1", true}, + {"multi-digit", "v1.9.0", "v1.10.0", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsNewerVersion(tt.current, tt.latest) + if got != tt.want { + t.Errorf("IsNewerVersion(%q, %q) = %v, want %v", tt.current, tt.latest, got, tt.want) + } + }) + } +} + +func TestParseVersion(t *testing.T) { + tests := []struct { + input string + valid bool + }{ + {"v1.2.3", true}, + {"1.2.3", true}, + {"v0.0.0", true}, + {"v1.2.3-rc1", true}, + {"invalid", false}, + {"v1.2", false}, + {"v1.2.3.4", false}, + {"v1.a.3", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := parseVersion(tt.input) + if tt.valid && result == nil { + t.Errorf("parseVersion(%q) returned nil, expected valid", tt.input) + } + if !tt.valid && result != nil { + t.Errorf("parseVersion(%q) returned %v, expected nil", tt.input, result) + } + }) + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go index f0c44d86..aa219003 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -490,6 +490,10 @@ type AppModel struct { isFirstLoad bool // Track if this is the first load of tasks showWelcome bool // Show welcome message when kanban is empty onboardingShown bool // Track if we've already shown the onboarding (to prevent double-triggering) + + // Version upgrade notification + currentVersion string // Current binary version (e.g. "v0.1.0" or "dev") + latestRelease *github.LatestRelease // Latest release from GitHub (nil if not checked yet or same version) } // taskExecutorDisplayName returns the display name for a task's executor. @@ -528,7 +532,7 @@ func (m *AppModel) updateTaskInList(task *db.Task) { } // NewAppModel creates a new application model. -func NewAppModel(database *db.DB, exec *executor.Executor, workingDir string) *AppModel { +func NewAppModel(database *db.DB, exec *executor.Executor, workingDir string, version ...string) *AppModel { // Initialize logger and log startup log := GetLogger() log.Info("=== TaskYou TUI starting ===") @@ -604,6 +608,11 @@ func NewAppModel(database *db.DB, exec *executor.Executor, workingDir string) *A showWelcome: false, // Will be set true when kanban is empty } + // Set version if provided + if len(version) > 0 { + model.currentVersion = version[0] + } + return model } @@ -636,7 +645,14 @@ func (m *AppModel) Init() tea.Cmd { } } - return tea.Batch(m.loadTasks(), m.waitForTaskEvent(), m.waitForDBChange(), m.tick(), m.prRefreshTick()) + cmds := []tea.Cmd{m.loadTasks(), m.waitForTaskEvent(), m.waitForDBChange(), m.tick(), m.prRefreshTick()} + + // Check for version upgrades in the background + if m.currentVersion != "" && m.currentVersion != "dev" { + cmds = append(cmds, m.checkVersion()) + } + + return tea.Batch(cmds...) } // Update handles messages. @@ -1243,6 +1259,11 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Continue watching for more changes cmds = append(cmds, m.waitForDBChange()) + case versionCheckMsg: + if msg.release != nil { + m.latestRelease = msg.release + } + default: // Route unknown messages to detail view if active // This handles async messages like panesJoinedMsg and spinnerTickMsg @@ -1406,6 +1427,18 @@ func (m *AppModel) viewDashboard() string { headerParts = append(headerParts, dangerStyle.Render(IconBlocked()+" DANGEROUS MODE ENABLED")) } + // Show version upgrade notification + if m.latestRelease != nil { + upgradeStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("#61AFEF")). // Blue background + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true). + Padding(0, 2). + Width(m.width) + headerParts = append(headerParts, upgradeStyle.Render( + fmt.Sprintf("Update available: %s → %s (run: ty upgrade)", m.currentVersion, m.latestRelease.Version))) + } + // Show notification banner if active if m.notification != "" && time.Now().Before(m.notifyUntil) { notifyStyle := lipgloss.NewStyle(). @@ -3529,6 +3562,10 @@ type aiCommandMsg struct { err error } +type versionCheckMsg struct { + release *github.LatestRelease +} + const maxDoneTasksInKanban = 20 const summaryRefreshAfter = 5 * time.Minute @@ -4234,6 +4271,17 @@ func (m *AppModel) prRefreshTick() tea.Cmd { }) } +// checkVersion fetches the latest release from GitHub and compares with current version. +func (m *AppModel) checkVersion() tea.Cmd { + return func() tea.Msg { + release := github.FetchLatestRelease() + if release != nil && github.IsNewerVersion(m.currentVersion, release.Version) { + return versionCheckMsg{release: release} + } + return versionCheckMsg{release: nil} + } +} + // fetchPRInfo fetches PR info for a single task (used for detail view). func (m *AppModel) fetchPRInfo(task *db.Task) tea.Cmd { if task.BranchName == "" || m.prCache == nil {