Add todo mode and replace the plan with TODO.md
This commit is contained in:
@@ -3,9 +3,12 @@ package agents
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/claude"
|
||||
"g.fc.run/misc/byre/internal/todo"
|
||||
)
|
||||
|
||||
const guidance = `You are running inside byre. The human reads your work in byre's web UI, not a terminal.
|
||||
@@ -55,11 +58,21 @@ func (m *Manager) bounceProseQuestion(l *live) claude.HookFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func remindGuidance(ctx context.Context, input json.RawMessage) (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"hookSpecificOutput": map[string]any{
|
||||
"hookEventName": "UserPromptSubmit",
|
||||
"additionalContext": guidance,
|
||||
},
|
||||
}, nil
|
||||
func taskGuidance(task api.Task) string {
|
||||
if task.TodoItem == nil {
|
||||
return guidance
|
||||
}
|
||||
return guidance + fmt.Sprintf("\n- This task was started from the item %q in %s at the repo root. When the work is done, remove that item from %s in this worktree, so the removal lands in the same merge as the work.",
|
||||
*task.TodoItem, todo.File, todo.File)
|
||||
}
|
||||
|
||||
func remindGuidance(text string) claude.HookFunc {
|
||||
return func(ctx context.Context, input json.RawMessage) (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"hookSpecificOutput": map[string]any{
|
||||
"hookEventName": "UserPromptSubmit",
|
||||
"additionalContext": text,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,15 +559,16 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
|
||||
projectID: task.ProjectId,
|
||||
}
|
||||
|
||||
g := taskGuidance(task)
|
||||
opts := claude.Options{
|
||||
Cwd: task.WorktreePath,
|
||||
Model: sess.Model,
|
||||
GitName: creator.GitName,
|
||||
GitEmail: creator.GitEmail,
|
||||
Guidance: guidance,
|
||||
Guidance: g,
|
||||
MCPServers: []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: []mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.createTaskTool(l), m.setMergeTitleTool(l)}}},
|
||||
Hooks: map[string][]claude.HookMatcher{
|
||||
"UserPromptSubmit": {{Func: remindGuidance}},
|
||||
"UserPromptSubmit": {{Func: remindGuidance(g)}},
|
||||
"Stop": {{Func: m.bounceProseQuestion(l)}},
|
||||
"PostToolUse": {{Func: m.snapshotAfterTool(l)}},
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ package agents_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -873,6 +874,68 @@ func TestCreateTaskTool(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartTodo(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
m := e.manager()
|
||||
|
||||
writeTestFile(t, filepath.Join(e.repo, "TODO.md"), "# Todo\n\n- Create hello.txt\n It holds the single line `hi`.\n- Leave this one alone\n")
|
||||
gitOut(t, e.repo, "add", "TODO.md")
|
||||
gitOut(t, e.repo, "commit", "--quiet", "--message", "add todo")
|
||||
|
||||
_, err := m.StartTodo(e.ctx, e.project, e.user, "No such item", testModel)
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("start unknown item: %v", err)
|
||||
}
|
||||
|
||||
task, err := m.StartTodo(e.ctx, e.project, e.user, "Create hello.txt", testModel)
|
||||
if err != nil {
|
||||
t.Fatalf("start todo: %v", err)
|
||||
}
|
||||
if task.TodoItem == nil || *task.TodoItem != "Create hello.txt" || task.Title != "Create hello.txt" {
|
||||
t.Fatalf("todo task %+v", task)
|
||||
}
|
||||
|
||||
_, err = m.StartTodo(e.ctx, e.project, e.user, "Create hello.txt", testModel)
|
||||
if !errors.Is(err, agents.ErrConflict) {
|
||||
t.Fatalf("second start of the same item: %v", err)
|
||||
}
|
||||
|
||||
events := e.waitFor("todo turn end", func(ev api.Event) bool {
|
||||
return ev.Kind == "turn.ended" && ev.TaskId != nil && *ev.TaskId == task.Id
|
||||
})
|
||||
for _, ev := range events {
|
||||
if ev.Kind == "user.message" && ev.TaskId != nil && *ev.TaskId == task.Id {
|
||||
text, _ := ev.Payload["text"].(string)
|
||||
if !strings.Contains(text, "- Create hello.txt\n It holds the single line `hi`.") || !strings.Contains(text, "remove this item from TODO.md") {
|
||||
t.Fatalf("first prompt %q", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stored, err := e.store.GetTask(e.ctx, task.Id)
|
||||
if err != nil || stored.TodoItem == nil || *stored.TodoItem != "Create hello.txt" {
|
||||
t.Fatalf("stored task %+v %v", stored, err)
|
||||
}
|
||||
|
||||
todoAfter, err := os.ReadFile(filepath.Join(task.WorktreePath, "TODO.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read worktree TODO.md: %v", err)
|
||||
}
|
||||
t.Logf("TODO.md after the turn:\n%s", todoAfter)
|
||||
if strings.Contains(string(todoAfter), "Create hello.txt") || !strings.Contains(string(todoAfter), "Leave this one alone") {
|
||||
t.Fatalf("agent didn't remove exactly its item from TODO.md")
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
err := os.WriteFile(path, []byte(content), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuestionReachesInbox(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
|
||||
@@ -13,9 +13,58 @@ import (
|
||||
"g.fc.run/misc/byre/internal/git"
|
||||
"g.fc.run/misc/byre/internal/mcp"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
"g.fc.run/misc/byre/internal/todo"
|
||||
)
|
||||
|
||||
func (m *Manager) CreateTask(ctx context.Context, project api.Project, title, createdBy, base string) (api.Task, error) {
|
||||
return m.createTask(ctx, project, title, createdBy, base, nil)
|
||||
}
|
||||
|
||||
func (m *Manager) StartTodo(ctx context.Context, project api.Project, creator api.User, title, model string) (api.Task, error) {
|
||||
if creator.GitName == "" || creator.GitEmail == "" {
|
||||
return api.Task{}, ErrNoGitIdentity
|
||||
}
|
||||
|
||||
src, found, err := git.ReadFile(project.RepoPath, project.DefaultBranch, todo.File)
|
||||
if err != nil {
|
||||
return api.Task{}, err
|
||||
}
|
||||
if !found {
|
||||
return api.Task{}, fmt.Errorf("%w: %s has no %s", store.ErrNotFound, project.DefaultBranch, todo.File)
|
||||
}
|
||||
item, found := todo.Find(todo.Parse(src), title)
|
||||
if !found {
|
||||
return api.Task{}, fmt.Errorf("%w: %s on %s has no item %q", store.ErrNotFound, todo.File, project.DefaultBranch, title)
|
||||
}
|
||||
|
||||
tasks, err := m.store.ListTasks(ctx, project.Id)
|
||||
if err != nil {
|
||||
return api.Task{}, err
|
||||
}
|
||||
for _, t := range tasks {
|
||||
if t.Status == api.TaskStatusActive && t.TodoItem != nil && *t.TodoItem == title {
|
||||
return api.Task{}, fmt.Errorf("%w: task %s is already working on %q", ErrConflict, t.Id, title)
|
||||
}
|
||||
}
|
||||
|
||||
task, err := m.createTask(ctx, project, item.Title, creator.Login, project.DefaultBranch, &item.Title)
|
||||
if err != nil {
|
||||
return api.Task{}, err
|
||||
}
|
||||
|
||||
_, err = m.create(ctx, task, creator, todoPrompt(item), model, creator.Login, nil)
|
||||
if err != nil {
|
||||
return api.Task{}, fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err)
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func todoPrompt(item api.TodoItem) string {
|
||||
return fmt.Sprintf("Work on this item from %[1]s at the repo root:\n\n%[2]s\n\nWhen the work is done, remove this item from %[1]s in this worktree, so the removal lands in the same merge as the work.",
|
||||
todo.File, todo.Markdown(item))
|
||||
}
|
||||
|
||||
func (m *Manager) createTask(ctx context.Context, project api.Project, title, createdBy, base string, todoItem *string) (api.Task, error) {
|
||||
id := store.NewID()
|
||||
t := api.Task{
|
||||
Id: id,
|
||||
@@ -27,6 +76,7 @@ func (m *Manager) CreateTask(ctx context.Context, project api.Project, title, cr
|
||||
Status: api.TaskStatusActive,
|
||||
CreatedBy: createdBy,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
TodoItem: todoItem,
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
+211
-112
@@ -675,11 +675,14 @@ type Task struct {
|
||||
MergeCommit *string `json:"merge_commit,omitempty"`
|
||||
|
||||
// MergeTitle The title of the squash commit Merge makes: the task title until the agent sets another with set_merge_title.
|
||||
MergeTitle string `json:"merge_title"`
|
||||
ProjectId string `json:"project_id"`
|
||||
Status TaskStatus `json:"status"`
|
||||
Title string `json:"title"`
|
||||
WorktreePath string `json:"worktree_path"`
|
||||
MergeTitle string `json:"merge_title"`
|
||||
ProjectId string `json:"project_id"`
|
||||
Status TaskStatus `json:"status"`
|
||||
Title string `json:"title"`
|
||||
|
||||
// TodoItem The title of the TODO.md item the task was started from; absent for other tasks.
|
||||
TodoItem *string `json:"todo_item,omitempty"`
|
||||
WorktreePath string `json:"worktree_path"`
|
||||
}
|
||||
|
||||
// TaskStatus defines model for Task.Status.
|
||||
@@ -700,6 +703,33 @@ type TaskCreate struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// Todo defines model for Todo.
|
||||
type Todo struct {
|
||||
// Exists Whether the default branch has a TODO.md at its root.
|
||||
Exists bool `json:"exists"`
|
||||
Items []TodoItem `json:"items"`
|
||||
}
|
||||
|
||||
// TodoItem defines model for TodoItem.
|
||||
type TodoItem struct {
|
||||
// Body The item's further lines
|
||||
Body string `json:"body"`
|
||||
|
||||
// Section The text of the nearest heading above the item; empty when there is none.
|
||||
Section string `json:"section"`
|
||||
|
||||
// Title The item's first line, without its list marker: the item's identity.
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// TodoStart defines model for TodoStart.
|
||||
type TodoStart struct {
|
||||
Model *string `json:"model,omitempty"`
|
||||
|
||||
// Title The title of the item to start.
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// TurnRef defines model for TurnRef.
|
||||
type TurnRef struct {
|
||||
SessionId string `json:"session_id"`
|
||||
@@ -813,6 +843,9 @@ type CreateProjectJSONRequestBody = ProjectCreate
|
||||
// CreateTaskJSONRequestBody defines body for CreateTask for application/json ContentType.
|
||||
type CreateTaskJSONRequestBody = TaskCreate
|
||||
|
||||
// StartTodoItemJSONRequestBody defines body for StartTodoItem for application/json ContentType.
|
||||
type StartTodoItemJSONRequestBody = TodoStart
|
||||
|
||||
// SendMessageJSONRequestBody defines body for SendMessage for application/json ContentType.
|
||||
type SendMessageJSONRequestBody = MessageCreate
|
||||
|
||||
@@ -873,6 +906,12 @@ type ServerInterface interface {
|
||||
// (POST /api/projects/{projectId}/tasks)
|
||||
CreateTask(w http.ResponseWriter, r *http.Request, projectId string)
|
||||
|
||||
// (GET /api/projects/{projectId}/todo)
|
||||
GetTodo(w http.ResponseWriter, r *http.Request, projectId string)
|
||||
|
||||
// (POST /api/projects/{projectId}/todo/tasks)
|
||||
StartTodoItem(w http.ResponseWriter, r *http.Request, projectId string)
|
||||
|
||||
// (GET /api/push/key)
|
||||
GetPushKey(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
@@ -1349,6 +1388,58 @@ func (siw *ServerInterfaceWrapper) CreateTask(w http.ResponseWriter, r *http.Req
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetTodo operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetTodo(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
_ = err
|
||||
|
||||
// ------------- Path parameter "projectId" -------------
|
||||
var projectId string
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "projectId", r.PathValue("projectId"), &projectId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "projectId", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.GetTodo(w, r, projectId)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// StartTodoItem operation middleware
|
||||
func (siw *ServerInterfaceWrapper) StartTodoItem(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
_ = err
|
||||
|
||||
// ------------- Path parameter "projectId" -------------
|
||||
var projectId string
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "projectId", r.PathValue("projectId"), &projectId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "projectId", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.StartTodoItem(w, r, projectId)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetPushKey operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetPushKey(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -2036,6 +2127,8 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/projects/{projectId}", wrapper.GetProject)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/projects/{projectId}/tasks", wrapper.ListTasks)
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/projects/{projectId}/tasks", wrapper.CreateTask)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/projects/{projectId}/todo", wrapper.GetTodo)
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/projects/{projectId}/todo/tasks", wrapper.StartTodoItem)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}", wrapper.GetTask)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/unmerged", wrapper.GetUnmerged)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/co-authors", wrapper.GetCoAuthors)
|
||||
@@ -2068,113 +2161,119 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
|
||||
// const string: with thousands of chunks the chained `+` fold is several
|
||||
// times slower for the Go compiler than parsing a slice literal.
|
||||
var swaggerSpec = []string{
|
||||
"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",
|
||||
"3hf0mIcswhpjBy3883CdzuRfNqN1f//fAQAA//8=",
|
||||
"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",
|
||||
}
|
||||
|
||||
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
|
||||
|
||||
@@ -236,6 +236,18 @@ func Head(dir string) (string, error) {
|
||||
return run(dir, "rev-parse", "HEAD")
|
||||
}
|
||||
|
||||
func ReadFile(repo, rev, path string) (string, bool, error) {
|
||||
_, _, code, err := runCode(repo, nil, "cat-file", "-e", rev+":"+path)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if code != 0 {
|
||||
return "", false, nil
|
||||
}
|
||||
out, err := run(repo, "cat-file", "blob", rev+":"+path)
|
||||
return out, true, err
|
||||
}
|
||||
|
||||
func CommitAll(dir, message, name, email string) (string, error) {
|
||||
status, err := run(dir, "status", "--porcelain", "--untracked-files=all")
|
||||
if err != nil {
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"g.fc.run/misc/byre/internal/git"
|
||||
"g.fc.run/misc/byre/internal/push"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
"g.fc.run/misc/byre/internal/todo"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
@@ -272,6 +273,57 @@ func (s *Server) CreateTask(w http.ResponseWriter, r *http.Request, projectID st
|
||||
writeJSON(w, http.StatusCreated, t)
|
||||
}
|
||||
|
||||
func (s *Server) GetTodo(w http.ResponseWriter, r *http.Request, projectID string) {
|
||||
p, err := s.store.GetProject(r.Context(), projectID)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
src, found, err := git.ReadFile(p.RepoPath, p.DefaultBranch, todo.File)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, api.Todo{Exists: found, Items: todo.Parse(src)})
|
||||
}
|
||||
|
||||
func (s *Server) StartTodoItem(w http.ResponseWriter, r *http.Request, projectID string) {
|
||||
req := api.TodoStart{}
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Title) == "" {
|
||||
writeError(w, http.StatusBadRequest, errors.New("title is required"))
|
||||
return
|
||||
}
|
||||
|
||||
p, err := s.store.GetProject(r.Context(), projectID)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
u, err := s.store.GetUser(r.Context(), actor(r))
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
model := ""
|
||||
if req.Model != nil {
|
||||
model = *req.Model
|
||||
}
|
||||
|
||||
t, err := s.agents.StartTodo(r.Context(), p, u, req.Title, model)
|
||||
if err != nil {
|
||||
writeAgentError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, t)
|
||||
}
|
||||
|
||||
func (s *Server) GetTask(w http.ResponseWriter, r *http.Request, taskID string) {
|
||||
t, err := s.store.GetTask(r.Context(), taskID)
|
||||
if err != nil {
|
||||
|
||||
+15
-5
@@ -206,6 +206,8 @@ UPDATE inbox_items SET payload = json_set(json_remove(payload, '$.recommended_op
|
||||
WHERE kind = 'question' AND json_type(payload, '$.recommended_option') IS NOT NULL;
|
||||
`}, {"unsnooze-inbox-items", `
|
||||
UPDATE inbox_items SET snoozed_until = NULL WHERE snoozed_until IS NOT NULL;
|
||||
`}, {"add-task-todo-item", `
|
||||
ALTER TABLE tasks ADD COLUMN todo_item TEXT NOT NULL DEFAULT '';
|
||||
`}}
|
||||
|
||||
type Store struct {
|
||||
@@ -426,11 +428,15 @@ func (s *Store) GetProject(ctx context.Context, id string) (api.Project, error)
|
||||
}
|
||||
|
||||
func (s *Store) CreateTask(ctx context.Context, t api.Task) error {
|
||||
todoItem := ""
|
||||
if t.TodoItem != nil {
|
||||
todoItem = *t.TodoItem
|
||||
}
|
||||
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tasks (id, project_id, title, branch, worktree_path, base_ref, status, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, t.Id, t.ProjectId, t.Title, t.Branch, t.WorktreePath, t.BaseRef, string(t.Status), t.CreatedBy, formatTime(t.CreatedAt))
|
||||
INSERT INTO tasks (id, project_id, title, branch, worktree_path, base_ref, status, created_by, created_at, todo_item)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, t.Id, t.ProjectId, t.Title, t.Branch, t.WorktreePath, t.BaseRef, string(t.Status), t.CreatedBy, formatTime(t.CreatedAt), todoItem)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -801,7 +807,7 @@ func (s *Store) write(ctx context.Context, fn func(tx *sql.Tx, emit emitFunc) er
|
||||
|
||||
const projectColumns = `id, name, repo_path, default_branch, remote, created_by, created_at`
|
||||
|
||||
const taskColumns = `id, project_id, title, merge_title, branch, worktree_path, base_ref, status, created_by, created_at, closed_at, merge_commit`
|
||||
const taskColumns = `id, project_id, title, merge_title, branch, worktree_path, base_ref, status, created_by, created_at, closed_at, merge_commit, todo_item`
|
||||
|
||||
const sessionColumns = `id, task_id, vendor, model, vendor_session_id, state, role, created_by, created_at`
|
||||
|
||||
@@ -826,7 +832,8 @@ func scanTask(row scanner) (api.Task, error) {
|
||||
createdAt := ""
|
||||
closedAt := sql.NullString{}
|
||||
mergeCommit := ""
|
||||
err := row.Scan(&t.Id, &t.ProjectId, &t.Title, &t.MergeTitle, &t.Branch, &t.WorktreePath, &t.BaseRef, &status, &t.CreatedBy, &createdAt, &closedAt, &mergeCommit)
|
||||
todoItem := ""
|
||||
err := row.Scan(&t.Id, &t.ProjectId, &t.Title, &t.MergeTitle, &t.Branch, &t.WorktreePath, &t.BaseRef, &status, &t.CreatedBy, &createdAt, &closedAt, &mergeCommit, &todoItem)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
@@ -848,6 +855,9 @@ func scanTask(row scanner) (api.Task, error) {
|
||||
if mergeCommit != "" {
|
||||
t.MergeCommit = &mergeCommit
|
||||
}
|
||||
if todoItem != "" {
|
||||
t.TodoItem = &todoItem
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package todo
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
)
|
||||
|
||||
const File = "TODO.md"
|
||||
|
||||
var (
|
||||
heading = regexp.MustCompile(`^#{1,6}\s+(.*?)\s*#*\s*$`)
|
||||
listItem = regexp.MustCompile(`^(?:[-*+]|\d+[.)])\s+(.*)$`)
|
||||
checkbox = regexp.MustCompile(`^\[([ xX])\]\s+`)
|
||||
fence = regexp.MustCompile("^\\s*(```|~~~)")
|
||||
)
|
||||
|
||||
func Parse(src string) []api.TodoItem {
|
||||
items := []api.TodoItem{}
|
||||
section := ""
|
||||
var cur *api.TodoItem
|
||||
body := []string{}
|
||||
inFence := false
|
||||
blank := false
|
||||
|
||||
finish := func() {
|
||||
if cur == nil {
|
||||
return
|
||||
}
|
||||
cur.Body = dedent(body)
|
||||
items = append(items, *cur)
|
||||
cur = nil
|
||||
body = []string{}
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(strings.ReplaceAll(src, "\r\n", "\n"), "\n") {
|
||||
if fence.MatchString(line) {
|
||||
inFence = !inFence
|
||||
if cur != nil {
|
||||
body = append(body, line)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if inFence {
|
||||
if cur != nil {
|
||||
body = append(body, line)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.TrimSpace(line) == "" {
|
||||
blank = true
|
||||
if cur != nil {
|
||||
body = append(body, "")
|
||||
}
|
||||
continue
|
||||
}
|
||||
indented := line[0] == ' ' || line[0] == '\t'
|
||||
afterBlank := blank
|
||||
blank = false
|
||||
|
||||
if m := heading.FindStringSubmatch(line); m != nil {
|
||||
finish()
|
||||
section = m[1]
|
||||
continue
|
||||
}
|
||||
if m := listItem.FindStringSubmatch(line); m != nil {
|
||||
finish()
|
||||
title := m[1]
|
||||
box := checkbox.FindStringSubmatch(title)
|
||||
if box != nil && box[1] != " " {
|
||||
continue
|
||||
}
|
||||
title = strings.TrimSpace(checkbox.ReplaceAllString(title, ""))
|
||||
cur = &api.TodoItem{Title: title, Section: section}
|
||||
continue
|
||||
}
|
||||
if cur == nil {
|
||||
continue
|
||||
}
|
||||
if !indented && afterBlank {
|
||||
finish()
|
||||
continue
|
||||
}
|
||||
body = append(body, line)
|
||||
}
|
||||
finish()
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
func Find(items []api.TodoItem, title string) (api.TodoItem, bool) {
|
||||
for _, item := range items {
|
||||
if item.Title == title {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return api.TodoItem{}, false
|
||||
}
|
||||
|
||||
func Markdown(item api.TodoItem) string {
|
||||
if item.Body == "" {
|
||||
return "- " + item.Title
|
||||
}
|
||||
lines := []string{"- " + item.Title}
|
||||
for _, line := range strings.Split(item.Body, "\n") {
|
||||
if line == "" {
|
||||
lines = append(lines, "")
|
||||
continue
|
||||
}
|
||||
lines = append(lines, " "+line)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func dedent(lines []string) string {
|
||||
for len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
|
||||
indent := -1
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
n := len(line) - len(strings.TrimLeft(line, " \t"))
|
||||
if indent < 0 || n < indent {
|
||||
indent = n
|
||||
}
|
||||
}
|
||||
|
||||
out := []string{}
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
out = append(out, "")
|
||||
continue
|
||||
}
|
||||
out = append(out, line[indent:])
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package todo_test
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/todo"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
src := "# TODO\n\n" +
|
||||
"Notes before the list are not items.\n\n" +
|
||||
"- Public key pinning\n" +
|
||||
" When libcurl4-openssl-dev >= 7.39.0, add support to the C client via the\n" +
|
||||
" options struct for public key pinning.\n" +
|
||||
"\n" +
|
||||
" ```\n" +
|
||||
" - not an item\n" +
|
||||
" ```\n" +
|
||||
"- [ ] Client ID rotation\n" +
|
||||
" - JS client\n" +
|
||||
" - C client\n" +
|
||||
"- [x] Already done\n" +
|
||||
" with a body\n" +
|
||||
"\n" +
|
||||
"## Web\n\n" +
|
||||
"1. Double-click zoom in\n" +
|
||||
"2. Pinch to zoom\n" +
|
||||
"\n" +
|
||||
"A trailing paragraph.\n"
|
||||
|
||||
want := []api.TodoItem{
|
||||
{Title: "Public key pinning", Section: "TODO", Body: "When libcurl4-openssl-dev >= 7.39.0, add support to the C client via the\noptions struct for public key pinning.\n\n```\n- not an item\n```"},
|
||||
{Title: "Client ID rotation", Section: "TODO", Body: "- JS client\n- C client"},
|
||||
{Title: "Double-click zoom in", Section: "Web"},
|
||||
{Title: "Pinch to zoom", Section: "Web"},
|
||||
}
|
||||
|
||||
got := todo.Parse(src)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("parse:\n got %#v\nwant %#v", got, want)
|
||||
}
|
||||
|
||||
item, ok := todo.Find(got, "Client ID rotation")
|
||||
if !ok {
|
||||
t.Fatalf("find: no item")
|
||||
}
|
||||
if md := todo.Markdown(item); md != "- Client ID rotation\n - JS client\n - C client" {
|
||||
t.Fatalf("markdown: %q", md)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEmpty(t *testing.T) {
|
||||
if got := todo.Parse(""); len(got) != 0 {
|
||||
t.Fatalf("parse empty: %#v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user