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 } err = m.store.SetMergingBy(ctx, task.Id, user.Login) if err != nil { return api.AgentSession{}, err } sess, err := m.MergeIn(ctx, task, user, project.DefaultBranch) if err != nil { m.takeMerge(task.Id) } return sess, err } func (m *Manager) takeMerge(taskID string) string { login, err := m.store.TakeMergingBy(context.Background(), taskID) if err != nil { slog.Error("take merge intent", "task", taskID, "err", err) } return login } 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 } coAuthors, err := m.CoAuthors(ctx, taskID, login) if err != nil { return err } commit, err := git.SquashCommit(project.RepoPath, task.WorktreePath, project.DefaultBranch, squashMessage(task.MergeTitle, coAuthors), []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) CoAuthors(ctx context.Context, taskID, login string) ([]api.Participant, error) { participants, err := m.store.Participants(ctx, taskID) if err != nil { return nil, err } coAuthors := []api.Participant{} for _, p := range participants { if p.Login != login { coAuthors = append(coAuthors, p) } } return coAuthors, nil } func squashMessage(title string, coAuthors []api.Participant) string { trailers := []string{} for _, p := range coAuthors { if p.GitName == "" || p.GitEmail == "" { continue } trailers = append(trailers, fmt.Sprintf("Co-authored-by: %s <%s>", p.GitName, p.GitEmail)) } if len(trailers) == 0 { return title } return title + "\n\n" + strings.Join(trailers, "\n") } func (m *Manager) Discard(ctx context.Context, task api.Task, project api.Project, actor string) (api.Task, error) { err := m.RemoveTask(ctx, task, project, actor) if err != nil { return api.Task{}, err } return m.store.DiscardTask(ctx, task.Id, actor) } 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 } err = m.dropSnapshots(project.RepoPath, task.Id) 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 }