Skip to content

Commit 77c7dfb

Browse files
committed
Implement sess end workflow
1 parent 55e9fc6 commit 77c7dfb

16 files changed

Lines changed: 1075 additions & 66 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
/sess
22
/sess.exe
3+
docs/INTRO-POST.md
4+
docs/LINKEDIN-POST.md
5+
docs/V0.2.2-UPDATE.md

README.md

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -239,15 +239,15 @@ sess projects
239239
# Last used: 2 hours ago
240240
```
241241

242-
### 8. End Session and Create PR (Coming in Phase 3)
242+
### 8. End Session and Create PR
243243

244244
```bash
245245
sess end
246-
# Prompts for PR description
247-
# Rebases onto dev
246+
# Prompts for commit/PR details when needed
247+
# Rebases onto the tracked base branch
248248
# Pushes branch
249-
# Opens PR linked to issue
250-
# Switches back to dev
249+
# Reuses or creates a PR
250+
# Switches back to the base branch
251251
```
252252

253253
---
@@ -261,7 +261,7 @@ sess end
261261
| `sess pause` |**MVP1** | Pause current session and stop time tracking |
262262
| `sess resume` |**MVP1** | Resume paused session and continue time tracking |
263263
| `sess projects` |**MVP1** | List all tracked projects across the system |
264-
| `sess end` | 🚧 [Phase 3 — #3](../../issues/3) | End session, commit, push, open PR |
264+
| `sess end` | **v0.3.0** | End session, commit, push, create or reuse PR |
265265
| `sess auth` | 🚧 [Phase 4 — #6](../../issues/6) | Authenticate with GitHub (currently uses `gh` auth) |
266266
| `sess config` | 🚧 [Phase 4 — #7](../../issues/7) | Initialize CLI in repo with custom settings |
267267

@@ -314,21 +314,20 @@ sess end
314314
- [x] Show session status for each project
315315
- [x] Display last used timestamps
316316

317-
### Phase 3: End-to-End Workflow 🚧 **NEXT**
317+
### Phase 3: End-to-End Workflow **DELIVERED IN v0.3.0**
318318

319319
**Goal:** Complete the session lifecycle from start to PR
320320

321-
- [ ] **`sess end` Command**[Issue #3](../../issues/3)
322-
- [ ] Interactive PR description input (use PR template if exists)
323-
- [ ] Commit all changes with user message
324-
- [ ] Rebase onto base branch (`dev`)
325-
- [ ] Conflict detection and resolution guidance
326-
- [ ] Push branch to remote
327-
- [ ] Create GitHub PR via `gh` CLI
328-
- [ ] Link PR to issue automatically
329-
- [ ] Switch back to base branch
330-
- [ ] Mark session as ended
331-
- [ ] Show session summary (duration, commits, PR link)
321+
- [x] **`sess end` Command**[Issue #3](../../issues/3)
322+
- [x] Interactive commit and PR description input
323+
- [x] Commit dirty work with a user-provided message
324+
- [x] Rebase onto the tracked base branch
325+
- [x] Push branch to remote
326+
- [x] Create or reuse a GitHub PR via `gh` CLI
327+
- [x] Link PR metadata to the ended session
328+
- [x] Switch back to the base branch
329+
- [x] Mark session as ended
330+
- [x] Show session summary (duration, PR link)
332331

333332
- [ ] **Conflict Handling**[Issue #4](../../issues/4)
334333
- [ ] Detect rebase conflicts
@@ -362,7 +361,8 @@ sess end
362361
- [x] **Session History** ✅ (MVP1)
363362
- [x] Store completed sessions in SQLite database
364363
- [x] Track: duration, issue, branch, state
365-
- [ ] Track: commits, PR link (Phase 3)
364+
- [x] Track: PR link (Phase 3)
365+
- [ ] Track: commits
366366

367367
- [ ] **Analytics Commands**[Issue #8](../../issues/8)
368368
- [ ] `sess history` - Show recent sessions

docs/IMPLEMENTATION-PLAN.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,10 @@ The session manager supports ending a session, but the CLI does not complete the
209209
- The resulting PR targets the configured base branch.
210210
- Session state is correct even if a later step fails.
211211

212+
**Follow-up**
213+
214+
- Investigate PTY-specific text-input truncation seen during automated `sess end` validation. The current command flow and prompt submission behavior are working, but a harness-driven repro still dropped the literal substring `end` inside one PR summary input chunk. Treat this as a lower-priority TUI/input-path bug and verify it against real terminal usage before changing prompt behavior further.
215+
212216
### 9. Add conflict and interrupted-workflow recovery
213217

214218
**Problem**

docs/NEXT-STEPS.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ This document consolidates the open GitHub issues and remaining roadmap phases s
88

99
| # | Title | Phase | Priority |
1010
|---|-------|-------|----------|
11-
| [#3](../../issues/3) | Implement `sess end` workflow | Phase 3 | 🔴 High |
1211
| [#4](../../issues/4) | Surface and recover from rebase or push conflicts | Phase 3 | 🔴 High |
1312
| [#5](../../issues/5) | Align CLI output with design guide | Cross-cutting | 🟡 Medium |
1413
| [#6](../../issues/6) | Add GitHub auth command | Phase 4 | 🟡 Medium |
@@ -19,12 +18,11 @@ This document consolidates the open GitHub issues and remaining roadmap phases s
1918

2019
## Recommended Work Order
2120

22-
### 1. Phase 3 — Complete the Session Lifecycle (Issues #3, #4)
21+
### 1. Phase 3 — Finish Conflict Recovery (Issue #4)
2322

24-
This is the most impactful next milestone. `sess end` closes the loop from session start to a merged PR, making SESS a complete tool rather than a partial one.
23+
`sess end` is now implemented. The remaining Phase 3 gap is robust recovery from rebase, push, or PR-creation interruptions.
2524

2625
**Start here:**
27-
- [#3 — Implement `sess end` workflow](../../issues/3): prompt for PR description, commit, rebase, push, open PR via `gh`, switch back to base branch, mark session ended.
2826
- [#4 — Conflict handling](../../issues/4): detect rebase conflicts, pause workflow gracefully, allow the user to resolve and resume.
2927

3028
### 2. Cross-Cutting — Output Quality (Issue #5)
@@ -73,3 +71,4 @@ These items from the roadmap do not yet have GitHub issues. Open issues as work
7371
- [docs/cli_design_guide.md](cli_design_guide.md)
7472
- [docs/technical-spec.md](technical-spec.md)
7573
- [docs/MVP1-SUMMARY.md](MVP1-SUMMARY.md)
74+
- [docs/IMPLEMENTATION-PLAN.md](IMPLEMENTATION-PLAN.md)

docs/RELEASING.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ We follow [Semantic Versioning](https://semver.org/):
116116

117117
- **v0.1.0** - Initial release with basic session start
118118
- **v0.2.0** - MVP1 - Database persistence, pause/resume, projects listing
119-
- **v0.3.0** - (Planned) Phase 3 - `sess end` command, PR creation
119+
- **v0.3.0** - Phase 3 - `sess end` command, PR creation
120120
- **v1.0.0** - (Future) Production-ready, stable API
121121

122122
## Pre-release Versions
@@ -396,8 +396,8 @@ If you encounter issues with the release process:
396396

397397
---
398398

399-
**Next Release:** v0.3.0 (Phase 3 - `sess end` command)
399+
**Next Release:** TBD
400400

401401
**Target Date:** TBD
402402

403-
**Features:** PR creation, rebase automation, session completion
403+
**Features:** conflict recovery, auth/config work, or other post-v0.3.0 priorities

internal/db/db.go

Lines changed: 62 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ type Session struct {
4242
CurrentSliceStart *time.Time // When the current active slice started
4343
TotalElapsed int64 // Duration in nanoseconds
4444
BranchType string
45+
PRNumber *int64
46+
PRURL string
4547
}
4648

4749
const (
@@ -120,6 +122,8 @@ func (db *DB) init() error {
120122
current_slice_start DATETIME,
121123
total_elapsed INTEGER NOT NULL DEFAULT 0,
122124
branch_type TEXT,
125+
pr_number INTEGER,
126+
pr_url TEXT,
123127
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
124128
);
125129
@@ -142,27 +146,45 @@ func (db *DB) init() error {
142146

143147
// runMigrations applies schema changes to existing databases
144148
func (db *DB) runMigrations() error {
145-
// Check if current_slice_start column exists
146-
var count int
147-
err := db.conn.QueryRow(`
148-
SELECT COUNT(*) FROM pragma_table_info('sessions')
149-
WHERE name = 'current_slice_start'
150-
`).Scan(&count)
151-
152-
if err != nil {
153-
return fmt.Errorf("failed to check for current_slice_start column: %w", err)
149+
migrations := []struct {
150+
column string
151+
definition string
152+
}{
153+
{column: "current_slice_start", definition: "DATETIME"},
154+
{column: "pr_number", definition: "INTEGER"},
155+
{column: "pr_url", definition: "TEXT"},
154156
}
155157

156-
// Add current_slice_start column if it doesn't exist
157-
if count == 0 {
158-
if _, err := db.conn.Exec(`ALTER TABLE sessions ADD COLUMN current_slice_start DATETIME`); err != nil {
159-
return fmt.Errorf("failed to add current_slice_start column: %w", err)
158+
for _, migration := range migrations {
159+
exists, err := db.hasSessionsColumn(migration.column)
160+
if err != nil {
161+
return fmt.Errorf("failed to check for %s column: %w", migration.column, err)
162+
}
163+
if exists {
164+
continue
165+
}
166+
167+
query := fmt.Sprintf("ALTER TABLE sessions ADD COLUMN %s %s", migration.column, migration.definition)
168+
if _, err := db.conn.Exec(query); err != nil {
169+
return fmt.Errorf("failed to add %s column: %w", migration.column, err)
160170
}
161171
}
162172

163173
return nil
164174
}
165175

176+
func (db *DB) hasSessionsColumn(name string) (bool, error) {
177+
var count int
178+
err := db.conn.QueryRow(`
179+
SELECT COUNT(*) FROM pragma_table_info('sessions')
180+
WHERE name = ?
181+
`, name).Scan(&count)
182+
if err != nil {
183+
return false, err
184+
}
185+
return count > 0, nil
186+
}
187+
166188
// Close closes the database connection
167189
func (db *DB) Close() error {
168190
if db.conn != nil {
@@ -289,9 +311,9 @@ func (db *DB) UpdateProject(p *Project) error {
289311
// CreateSession creates a new session
290312
func (db *DB) CreateSession(s *Session) (*Session, error) {
291313
result, err := db.conn.Exec(
292-
`INSERT INTO sessions (project_id, branch, issue_id, issue_title, state, start_time, current_slice_start, total_elapsed, branch_type)
293-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
294-
s.ProjectID, s.Branch, s.IssueID, s.IssueTitle, s.State, s.StartTime, s.CurrentSliceStart, s.TotalElapsed, s.BranchType,
314+
`INSERT INTO sessions (project_id, branch, issue_id, issue_title, state, start_time, current_slice_start, total_elapsed, branch_type, pr_number, pr_url)
315+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
316+
s.ProjectID, s.Branch, s.IssueID, s.IssueTitle, s.State, s.StartTime, s.CurrentSliceStart, s.TotalElapsed, s.BranchType, s.PRNumber, s.PRURL,
295317
)
296318
if err != nil {
297319
return nil, fmt.Errorf("failed to create session: %w", err)
@@ -310,16 +332,19 @@ func (db *DB) CreateSession(s *Session) (*Session, error) {
310332
func (db *DB) GetActiveSession(projectID int64) (*Session, error) {
311333
var s Session
312334
var pauseTime, endTime, currentSliceStart sql.NullTime
335+
var prNumber sql.NullInt64
336+
var prURL sql.NullString
313337

314338
err := db.conn.QueryRow(
315339
`SELECT id, project_id, branch, COALESCE(issue_id, ''), COALESCE(issue_title, ''),
316-
state, start_time, pause_time, end_time, current_slice_start, total_elapsed, COALESCE(branch_type, '')
340+
state, start_time, pause_time, end_time, current_slice_start, total_elapsed, COALESCE(branch_type, ''),
341+
pr_number, COALESCE(pr_url, '')
317342
FROM sessions
318343
WHERE project_id = ? AND state IN ('active', 'paused')
319344
ORDER BY start_time DESC LIMIT 1`,
320345
projectID,
321346
).Scan(&s.ID, &s.ProjectID, &s.Branch, &s.IssueID, &s.IssueTitle, &s.State,
322-
&s.StartTime, &pauseTime, &endTime, &currentSliceStart, &s.TotalElapsed, &s.BranchType)
347+
&s.StartTime, &pauseTime, &endTime, &currentSliceStart, &s.TotalElapsed, &s.BranchType, &prNumber, &prURL)
323348

324349
if err == sql.ErrNoRows {
325350
return nil, nil
@@ -337,6 +362,12 @@ func (db *DB) GetActiveSession(projectID int64) (*Session, error) {
337362
if currentSliceStart.Valid {
338363
s.CurrentSliceStart = &currentSliceStart.Time
339364
}
365+
if prNumber.Valid {
366+
s.PRNumber = &prNumber.Int64
367+
}
368+
if prURL.Valid {
369+
s.PRURL = prURL.String
370+
}
340371

341372
return &s, nil
342373
}
@@ -345,9 +376,9 @@ func (db *DB) GetActiveSession(projectID int64) (*Session, error) {
345376
func (db *DB) UpdateSession(s *Session) error {
346377
_, err := db.conn.Exec(
347378
`UPDATE sessions
348-
SET state = ?, pause_time = ?, end_time = ?, current_slice_start = ?, total_elapsed = ?
379+
SET state = ?, pause_time = ?, end_time = ?, current_slice_start = ?, total_elapsed = ?, pr_number = ?, pr_url = ?
349380
WHERE id = ?`,
350-
s.State, s.PauseTime, s.EndTime, s.CurrentSliceStart, s.TotalElapsed, s.ID,
381+
s.State, s.PauseTime, s.EndTime, s.CurrentSliceStart, s.TotalElapsed, s.PRNumber, s.PRURL, s.ID,
351382
)
352383
if err != nil {
353384
return fmt.Errorf("failed to update session: %w", err)
@@ -359,7 +390,8 @@ func (db *DB) UpdateSession(s *Session) error {
359390
func (db *DB) GetSessionHistory(projectID int64, limit int) ([]*Session, error) {
360391
rows, err := db.conn.Query(
361392
`SELECT id, project_id, branch, COALESCE(issue_id, ''), COALESCE(issue_title, ''),
362-
state, start_time, pause_time, end_time, current_slice_start, total_elapsed, COALESCE(branch_type, '')
393+
state, start_time, pause_time, end_time, current_slice_start, total_elapsed, COALESCE(branch_type, ''),
394+
pr_number, COALESCE(pr_url, '')
363395
FROM sessions
364396
WHERE project_id = ?
365397
ORDER BY start_time DESC LIMIT ?`,
@@ -374,9 +406,11 @@ func (db *DB) GetSessionHistory(projectID int64, limit int) ([]*Session, error)
374406
for rows.Next() {
375407
var s Session
376408
var pauseTime, endTime, currentSliceStart sql.NullTime
409+
var prNumber sql.NullInt64
410+
var prURL sql.NullString
377411

378412
if err := rows.Scan(&s.ID, &s.ProjectID, &s.Branch, &s.IssueID, &s.IssueTitle,
379-
&s.State, &s.StartTime, &pauseTime, &endTime, &currentSliceStart, &s.TotalElapsed, &s.BranchType); err != nil {
413+
&s.State, &s.StartTime, &pauseTime, &endTime, &currentSliceStart, &s.TotalElapsed, &s.BranchType, &prNumber, &prURL); err != nil {
380414
return nil, fmt.Errorf("failed to scan session: %w", err)
381415
}
382416

@@ -389,6 +423,12 @@ func (db *DB) GetSessionHistory(projectID int64, limit int) ([]*Session, error)
389423
if currentSliceStart.Valid {
390424
s.CurrentSliceStart = &currentSliceStart.Time
391425
}
426+
if prNumber.Valid {
427+
s.PRNumber = &prNumber.Int64
428+
}
429+
if prURL.Valid {
430+
s.PRURL = prURL.String
431+
}
392432

393433
sessions = append(sessions, &s)
394434
}

internal/db/db_test.go

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,15 +87,60 @@ func TestOpenMigratesCurrentSliceStartColumn(t *testing.T) {
8787
}
8888
}()
8989

90+
assertSessionsColumnCount(t, database, "current_slice_start", 1)
91+
assertSessionsColumnCount(t, database, "pr_number", 1)
92+
assertSessionsColumnCount(t, database, "pr_url", 1)
93+
}
94+
95+
func TestSessionHistoryRoundTripsPRMetadata(t *testing.T) {
96+
database := newTestDB(t)
97+
project := newTestProject(t, database)
98+
99+
prNumber := int64(45)
100+
endTime := time.Now()
101+
102+
if _, err := database.CreateSession(&Session{
103+
ProjectID: project.ID,
104+
Branch: "feature/with-pr",
105+
State: StateEnded,
106+
StartTime: time.Now().Add(-time.Hour),
107+
EndTime: &endTime,
108+
CurrentSliceStart: nil,
109+
TotalElapsed: int64(30 * time.Minute),
110+
BranchType: "feature",
111+
PRNumber: &prNumber,
112+
PRURL: "https://github.com/example/repo/pull/45",
113+
}); err != nil {
114+
t.Fatalf("create session: %v", err)
115+
}
116+
117+
history, err := database.GetSessionHistory(project.ID, 10)
118+
if err != nil {
119+
t.Fatalf("get session history: %v", err)
120+
}
121+
if len(history) != 1 {
122+
t.Fatalf("history length = %d, want 1", len(history))
123+
}
124+
if history[0].PRNumber == nil || *history[0].PRNumber != prNumber {
125+
t.Fatalf("history PR number = %v, want %d", history[0].PRNumber, prNumber)
126+
}
127+
if history[0].PRURL != "https://github.com/example/repo/pull/45" {
128+
t.Fatalf("history PR URL = %q, want PR URL", history[0].PRURL)
129+
}
130+
}
131+
132+
func assertSessionsColumnCount(t *testing.T, database *DB, column string, want int) {
133+
t.Helper()
134+
90135
var count int
91136
if err := database.conn.QueryRow(`
92137
SELECT COUNT(*) FROM pragma_table_info('sessions')
93-
WHERE name = 'current_slice_start'
94-
`).Scan(&count); err != nil {
95-
t.Fatalf("query current_slice_start column: %v", err)
138+
WHERE name = ?
139+
`, column).Scan(&count); err != nil {
140+
t.Fatalf("query %s column: %v", column, err)
96141
}
97-
if count != 1 {
98-
t.Fatalf("current_slice_start column count = %d, want 1", count)
142+
if count != want {
143+
t.Fatalf("%s column count = %d, want %d", column, count, want)
99144
}
100145
}
101146

0 commit comments

Comments
 (0)