Let the agent set the merge commit title, defaulting to the task title
This commit is contained in:
@@ -554,7 +554,7 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
|
||||
GitName: creator.GitName,
|
||||
GitEmail: creator.GitEmail,
|
||||
Guidance: guidance,
|
||||
MCPServers: []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: []mcp.Tool{m.askTool(l), m.createTaskTool(l)}}},
|
||||
MCPServers: []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: []mcp.Tool{m.askTool(l), m.createTaskTool(l), m.setMergeTitleTool(l)}}},
|
||||
Hooks: map[string][]claude.HookMatcher{
|
||||
"UserPromptSubmit": {{Func: remindGuidance}},
|
||||
"Stop": {{Func: m.bounceProseQuestion(l)}},
|
||||
|
||||
@@ -612,9 +612,10 @@ func TestMergeResolvesConflict(t *testing.T) {
|
||||
if parent := gitOut(t, e.repo, "rev-parse", "main^"); parent != moved {
|
||||
t.Fatalf("merge commit parent %s, want %s", parent, moved)
|
||||
}
|
||||
if log := gitOut(t, e.repo, "log", "-1", "--format=%s|%an|%cn"); log != task.Title+"|"+gitName+"|"+gitName {
|
||||
t.Fatalf("unexpected merge commit %s", log)
|
||||
if log := gitOut(t, e.repo, "log", "-1", "--format=%s|%b|%an|%cn"); log != task.MergeTitle+"||"+gitName+"|"+gitName {
|
||||
t.Fatalf("unexpected merge commit %s, want title %q", log, task.MergeTitle)
|
||||
}
|
||||
t.Logf("merge commit title: %s", task.MergeTitle)
|
||||
notes, err := os.ReadFile(filepath.Join(e.repo, "notes.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("read notes.txt: %v", err)
|
||||
|
||||
@@ -2,17 +2,26 @@ package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/git"
|
||||
"g.fc.run/misc/byre/internal/mcp"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
func mergeInPrompt(target string) string {
|
||||
const maxMergeTitle = 72
|
||||
|
||||
func mergeInPrompt(target, mergeTitle string) string {
|
||||
return fmt.Sprintf("Merge %[1]s into this branch: run `git merge --no-ff --no-commit %[1]s`. "+
|
||||
"Resolve any conflicts keeping both sides' intent; regenerate generated files instead of hand-merging them. "+
|
||||
"Check that the build and tests pass. Leave the merge uncommitted: byre concludes it when the turn ends.", target)
|
||||
"Check that the build and tests pass. Leave the merge uncommitted: byre concludes it when the turn ends. "+
|
||||
"Finally, if the merge commit title (currently \"%[2]s\") no longer describes all the work on this branch, set a better one with set_merge_title.", target, mergeTitle)
|
||||
}
|
||||
|
||||
func (m *Manager) MergeIn(ctx context.Context, task api.Task, user api.User, target string) (api.AgentSession, error) {
|
||||
@@ -21,13 +30,14 @@ func (m *Manager) MergeIn(ctx context.Context, task api.Task, user api.User, tar
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
prompt := mergeInPrompt(target, task.MergeTitle)
|
||||
for _, sess := range sessions {
|
||||
if sess.State != api.AgentSessionStateCompleted {
|
||||
return m.Send(ctx, sess.Id, "byre", mergeInPrompt(target), "")
|
||||
return m.Send(ctx, sess.Id, "byre", prompt, "")
|
||||
}
|
||||
}
|
||||
|
||||
return m.create(ctx, task, user, mergeInPrompt(target), "", "byre", nil)
|
||||
return m.create(ctx, task, user, prompt, "", "byre", nil)
|
||||
}
|
||||
|
||||
func (m *Manager) Merge(ctx context.Context, task api.Task, project api.Project, user api.User) (api.AgentSession, error) {
|
||||
@@ -85,7 +95,7 @@ func (m *Manager) squashAndClose(ctx context.Context, taskID, login string) erro
|
||||
return err
|
||||
}
|
||||
|
||||
commit, err := git.SquashMerge(project.RepoPath, task.WorktreePath, project.DefaultBranch, task.Title, []string{
|
||||
commit, err := git.SquashMerge(project.RepoPath, task.WorktreePath, project.DefaultBranch, task.MergeTitle, []string{
|
||||
"GIT_AUTHOR_NAME=" + user.GitName,
|
||||
"GIT_AUTHOR_EMAIL=" + user.GitEmail,
|
||||
"GIT_COMMITTER_NAME=" + user.GitName,
|
||||
@@ -111,3 +121,51 @@ func (m *Manager) RemoveTask(ctx context.Context, task api.Task, project api.Pro
|
||||
}
|
||||
return git.RemoveWorktree(project.RepoPath, task.WorktreePath, task.Branch)
|
||||
}
|
||||
|
||||
type setMergeTitleArgs struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func (m *Manager) setMergeTitleTool(l *live) mcp.Tool {
|
||||
return mcp.Tool{
|
||||
Name: "set_merge_title",
|
||||
Description: "Set the title of the single commit this task becomes when it is merged onto the default branch. " +
|
||||
"It starts as the task title. Write one line in the imperative mood that describes all the work on the branch, " +
|
||||
"for example \"Add retry with backoff to webhook delivery\". It replaces any earlier title.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string", "description": "One line, at most 72 characters."}
|
||||
},
|
||||
"required": ["title"]
|
||||
}`),
|
||||
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
return m.setMergeTitle(ctx, l, raw)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) setMergeTitle(ctx context.Context, l *live, raw json.RawMessage) (string, error) {
|
||||
args := setMergeTitleArgs{}
|
||||
err := json.Unmarshal(raw, &args)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("malformed set_merge_title call: %w", err)
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(args.Title)
|
||||
if title == "" || strings.ContainsAny(title, "\r\n") {
|
||||
return "", errors.New("title must be one non-empty line")
|
||||
}
|
||||
if utf8.RuneCountInString(title) > maxMergeTitle {
|
||||
return "", fmt.Errorf("title is %d characters; keep it to %d", utf8.RuneCountInString(title), maxMergeTitle)
|
||||
}
|
||||
|
||||
err = m.store.SetMergeTitle(ctx, l.taskID, title, agentActor)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return "", errors.New("this task is no longer active")
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Merge commit title set to %q.", title), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
func TestSetMergeTitle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "byre.db"), func(api.Event) {})
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
|
||||
now := time.Now().UTC()
|
||||
_, err = st.EnsureUser(ctx, "alice", "Alice")
|
||||
if err != nil {
|
||||
t.Fatalf("ensure user: %v", err)
|
||||
}
|
||||
err = st.CreateProject(ctx, api.Project{Id: "p1", Name: "demo", RepoPath: "/nonexistent", DefaultBranch: "main", Remote: "none", CreatedBy: "alice", CreatedAt: now})
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
err = st.CreateTask(ctx, api.Task{Id: "t1", ProjectId: "p1", Title: "Fix the thing", Branch: "b", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now})
|
||||
if err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
|
||||
task, err := st.GetTask(ctx, "t1")
|
||||
if err != nil || task.MergeTitle != "Fix the thing" {
|
||||
t.Fatalf("default merge title %q, want the task title: %v", task.MergeTitle, err)
|
||||
}
|
||||
|
||||
m := New(st, DefaultLimits, t.TempDir())
|
||||
l := &live{id: "s1", vendor: api.Claude, taskID: "t1", projectID: "p1"}
|
||||
call := func(title string) error {
|
||||
raw, err := json.Marshal(setMergeTitleArgs{Title: title})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
_, err = m.setMergeTitle(ctx, l, raw)
|
||||
return err
|
||||
}
|
||||
|
||||
for _, bad := range []string{"", " ", "two\nlines", strings.Repeat("x", maxMergeTitle+1)} {
|
||||
if call(bad) == nil {
|
||||
t.Fatalf("set_merge_title accepted %q", bad)
|
||||
}
|
||||
}
|
||||
|
||||
err = call(" Add retry with backoff to webhook delivery ")
|
||||
if err != nil {
|
||||
t.Fatalf("set_merge_title: %v", err)
|
||||
}
|
||||
task, err = st.GetTask(ctx, "t1")
|
||||
if err != nil || task.MergeTitle != "Add retry with backoff to webhook delivery" || task.Title != "Fix the thing" {
|
||||
t.Fatalf("after set_merge_title: %+v %v", task, err)
|
||||
}
|
||||
|
||||
if !strings.Contains(mergeInPrompt("main", task.MergeTitle), `currently "Add retry with backoff to webhook delivery"`) {
|
||||
t.Fatalf("merge-in prompt doesn't show the current title: %s", mergeInPrompt("main", task.MergeTitle))
|
||||
}
|
||||
|
||||
_, err = st.DiscardTask(ctx, "t1", "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("discard: %v", err)
|
||||
}
|
||||
if call("Too late") == nil {
|
||||
t.Fatalf("set_merge_title accepted a title for a closed task")
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ func (m *Manager) CreateTask(ctx context.Context, project api.Project, title, cr
|
||||
Id: id,
|
||||
ProjectId: project.Id,
|
||||
Title: title,
|
||||
MergeTitle: title,
|
||||
Branch: "byre/" + id,
|
||||
WorktreePath: filepath.Join(m.worktreeRoot, project.Id, id),
|
||||
Status: api.TaskStatusActive,
|
||||
|
||||
Reference in New Issue
Block a user