Files
byre/internal/agents/mergein.go
T

190 lines
5.4 KiB
Go

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"
)
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. "+
"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) {
sessions, err := m.store.ListSessions(ctx, task.Id)
if err != nil {
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", prompt, "")
}
}
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) {
if user.GitName == "" || user.GitEmail == "" {
return api.AgentSession{}, ErrNoGitIdentity
}
err := git.FetchFastForward(project.RepoPath, project.DefaultBranch)
if err != nil {
return api.AgentSession{}, err
}
m.mu.Lock()
m.merges[task.Id] = user.Login
m.mu.Unlock()
sess, err := m.MergeIn(ctx, task, user, project.DefaultBranch)
if err != nil {
m.mu.Lock()
delete(m.merges, task.Id)
m.mu.Unlock()
}
return sess, err
}
func (m *Manager) takeMerge(taskID string) (string, bool) {
m.mu.Lock()
defer m.mu.Unlock()
login, ok := m.merges[taskID]
delete(m.merges, taskID)
return login, ok
}
func (m *Manager) finishMerge(l *live, login string) {
ctx := context.Background()
err := m.squashAndClose(ctx, l.taskID, login)
if err == nil {
return
}
slog.Error("finish merge", "task", l.taskID, "err", err)
m.fileError(l, "Merging the task failed: "+err.Error())
err = m.Deliver(ctx, l.id)
if err != nil {
slog.Error("deliver inbox responses", "session", l.id, "err", err)
}
}
func (m *Manager) squashAndClose(ctx context.Context, taskID, login string) error {
task, err := m.store.GetTask(ctx, taskID)
if err != nil {
return err
}
project, err := m.store.GetProject(ctx, task.ProjectId)
if err != nil {
return err
}
user, err := m.store.GetUser(ctx, login)
if err != nil {
return err
}
commit, err := git.SquashCommit(project.RepoPath, task.WorktreePath, project.DefaultBranch, task.MergeTitle, []string{
"GIT_AUTHOR_NAME=" + user.GitName,
"GIT_AUTHOR_EMAIL=" + user.GitEmail,
"GIT_COMMITTER_NAME=" + user.GitName,
"GIT_COMMITTER_EMAIL=" + user.GitEmail,
})
if err != nil {
return err
}
pushed := ""
if commit != "" {
err = git.Push(project.RepoPath, commit, project.DefaultBranch)
if err != nil {
return err
}
pushed = git.Remote + "/" + project.DefaultBranch
err = git.FastForward(project.RepoPath, commit)
if err != nil {
return fmt.Errorf("pushed %s to %s, but fast-forwarding the project checkout failed: %w", commit, pushed, err)
}
}
err = m.RemoveTask(ctx, task, project, login)
if err != nil {
return err
}
_, err = m.store.MergeTask(ctx, taskID, commit, pushed, login)
return err
}
func (m *Manager) RemoveTask(ctx context.Context, task api.Task, project api.Project, actor string) error {
err := m.StopTask(ctx, task.Id, actor)
if err != nil {
return err
}
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
}