Attach a refs/notes/byre note to each squash merge and push it

This commit is contained in:
Ian Gulliver
2026-10-15 19:44:53 -07:00
parent c5e3a680d8
commit 6741177f53
8 changed files with 275 additions and 11 deletions
+16 -3
View File
@@ -269,23 +269,36 @@ func (m *Manager) squashAndClose(ctx context.Context, taskID, login string) erro
return err
}
commit, err := git.SquashCommit(project.RepoPath, task.WorktreePath, project.DefaultBranch, squashMessage(task.MergeTitle, coAuthors), []string{
env := []string{
"GIT_AUTHOR_NAME=" + user.GitName,
"GIT_AUTHOR_EMAIL=" + user.GitEmail,
"GIT_COMMITTER_NAME=" + user.GitName,
"GIT_COMMITTER_EMAIL=" + user.GitEmail,
})
}
commit, err := git.SquashCommit(project.RepoPath, task.WorktreePath, project.DefaultBranch, squashMessage(task.MergeTitle, coAuthors), env)
if err != nil {
return err
}
pushed := ""
if commit != "" {
err = git.Push(project.RepoPath, commit, project.DefaultBranch)
note, err := m.mergeNote(ctx, task, login)
if err != nil {
return err
}
notes, err := git.NoteCommit(project.RepoPath, commit, note, env)
if err != nil {
return err
}
err = git.Push(project.RepoPath, commit, project.DefaultBranch, notes)
if err != nil {
return err
}
pushed = git.Remote + "/" + project.DefaultBranch
err = git.SetNotes(project.RepoPath, notes)
if err != nil {
return fmt.Errorf("pushed %s to %s, but updating %s in the project checkout failed: %w", commit, pushed, git.NotesRef, err)
}
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)
+107
View File
@@ -0,0 +1,107 @@
package agents
import (
"context"
"fmt"
"strings"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/store"
)
func (m *Manager) mergeNote(ctx context.Context, task api.Task, login string) (string, error) {
handoffs, err := m.store.TaskHandoffs(ctx, task.Id)
if err != nil {
return "", err
}
reviews, err := m.store.ListReviews(ctx, task.Id)
if err != nil {
return "", err
}
answered, err := m.store.ListInbox(ctx, store.InboxFilter{TaskID: task.Id, State: api.Answered})
if err != nil {
return "", err
}
b := &strings.Builder{}
fmt.Fprintf(b, "# %s\n\n", task.MergeTitle)
fmt.Fprintf(b, "byre task %s, %q, created by %s, merged by %s.\n", task.Id, task.Title, task.CreatedBy, login)
if task.TodoItem != nil {
fmt.Fprintf(b, "Started from the TODO item %q.\n", *task.TodoItem)
}
if task.ForkedFrom != nil && task.ForkedAt != nil {
fmt.Fprintf(b, "Forked from task %s at snapshot %d.\n", *task.ForkedFrom, *task.ForkedAt)
}
fmt.Fprintf(b, "Full transcripts: the events of task %s in byre (GET /api/events?task_id=%s).\n", task.Id, task.Id)
if len(handoffs) > 0 {
b.WriteString("\n## Handoffs\n")
for _, h := range handoffs {
fmt.Fprintf(b, "\n### Session %s, %s\n\n%s\n", h.SessionID, h.At.Format(time.RFC3339), strings.TrimSpace(h.Text))
}
}
if len(reviews) > 0 {
b.WriteString("\n## Reviews\n")
for _, r := range reviews {
fmt.Fprintf(b, "\n### Snapshot %d, %s %s: %s\n", r.SnapshotN, r.Vendor, r.Model, r.State)
if r.Error != "" {
fmt.Fprintf(b, "\n%s\n", r.Error)
}
if r.Result == nil {
continue
}
for _, key := range []string{"change", "summary"} {
text, _ := (*r.Result)[key].(string)
if strings.TrimSpace(text) != "" {
fmt.Fprintf(b, "\n%s\n", strings.TrimSpace(text))
}
}
}
}
decisions := []api.InboxItem{}
for _, item := range answered {
if item.Kind == api.Question {
decisions = append(decisions, item)
}
}
if len(decisions) > 0 {
b.WriteString("\n## Decisions\n")
for _, item := range decisions {
fmt.Fprintf(b, "\n### %s\n", item.Payload["question"])
background, _ := item.Payload["context"].(string)
if strings.TrimSpace(background) != "" {
fmt.Fprintf(b, "\n%s\n", strings.TrimSpace(background))
}
b.WriteString("\n")
for _, r := range item.Responses {
b.WriteString(decisionLine(item, r))
}
}
}
return b.String(), nil
}
func decisionLine(item api.InboxItem, r api.InboxResponse) string {
text := ""
if r.Text != nil {
text = strings.TrimSpace(*r.Text)
}
switch r.Type {
case api.Option:
line := fmt.Sprintf("%s chose: %s\n", r.User, strings.Join(*r.Options, "; "))
if item.Payload["multiple"] == true {
line = fmt.Sprintf("%s checked: %s\n", r.User, strings.Join(*r.Options, "; "))
}
if text != "" {
line += fmt.Sprintf("%s added: %s\n", r.User, text)
}
return line
case api.Other:
return fmt.Sprintf("%s answered: %s\n", r.User, text)
}
return ""
}
+20 -3
View File
@@ -3,6 +3,7 @@ package git
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
@@ -217,8 +218,8 @@ const Remote = "origin"
var ErrRemoteMoved = errors.New("the remote's branch has moved")
func Push(repo, commit, branch string) error {
out, stderr, code, err := runCode(repo, nil, "push", "--porcelain", Remote, commit+":refs/heads/"+branch)
func Push(repo, commit, branch, notes string) error {
out, stderr, code, err := runCode(repo, nil, "push", "--atomic", "--porcelain", Remote, commit+":refs/heads/"+branch, notes+":"+NotesRef)
if err != nil {
return err
}
@@ -239,7 +240,7 @@ func FastForward(repo, rev string) error {
}
func FetchFastForward(repo, branch string) error {
_, err := run(repo, "fetch", Remote, branch)
_, err := run(repo, "fetch", Remote)
if err != nil {
return err
}
@@ -303,10 +304,26 @@ func runEnv(dir string, env []string, args ...string) (string, error) {
return out, nil
}
func runInput(dir string, env []string, input string, args ...string) (string, error) {
out, stderr, code, err := runCodeInput(dir, env, strings.NewReader(input), args...)
if err != nil {
return "", err
}
if code != 0 {
return "", fmt.Errorf("git %s: exit status %d: %s", strings.Join(args, " "), code, stderr)
}
return out, nil
}
func runCode(dir string, env []string, args ...string) (string, string, int, error) {
return runCodeInput(dir, env, nil, args...)
}
func runCodeInput(dir string, env []string, stdin io.Reader, args ...string) (string, string, int, error) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), env...)
cmd.Stdin = stdin
stderr := &strings.Builder{}
cmd.Stderr = stderr
out, err := cmd.Output()
+75
View File
@@ -0,0 +1,75 @@
package git
import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
)
const NotesRef = "refs/notes/byre"
const notesFetch = NotesRef + "*:" + NotesRef + "*"
func ConfigureNotesFetch(repo string) error {
key := "remote." + Remote + ".fetch"
out, stderr, code, err := runCode(repo, nil, "config", "--get-all", key)
if err != nil {
return err
}
if code != 0 && code != 1 {
return fmt.Errorf("git config --get-all %s: exit status %d: %s", key, code, stderr)
}
if slices.Contains(strings.Split(out, "\n"), notesFetch) {
return nil
}
_, err = run(repo, "config", "--add", key, notesFetch)
return err
}
func NoteCommit(repo, commit, note string, env []string) (string, error) {
blob, err := runInput(repo, nil, note, "hash-object", "-w", "--stdin")
if err != nil {
return "", err
}
parent, stderr, code, err := runCode(repo, nil, "rev-parse", "--verify", "--quiet", NotesRef)
if err != nil {
return "", err
}
if code != 0 && code != 1 {
return "", fmt.Errorf("git rev-parse %s: exit status %d: %s", NotesRef, code, stderr)
}
dir, err := os.MkdirTemp("", "byre-notes-*")
if err != nil {
return "", err
}
defer os.RemoveAll(dir)
index := []string{"GIT_INDEX_FILE=" + filepath.Join(dir, "index")}
commitArgs := []string{"commit-tree", "-m", "Notes added by byre"}
if code == 0 {
_, err = runEnv(repo, index, "read-tree", parent)
if err != nil {
return "", err
}
commitArgs = append(commitArgs, "-p", parent)
}
_, err = runEnv(repo, index, "update-index", "--add", "--cacheinfo", "100644,"+blob+","+commit)
if err != nil {
return "", err
}
tree, err := runEnv(repo, index, "write-tree")
if err != nil {
return "", err
}
return runEnv(repo, env, append(commitArgs, tree)...)
}
func SetNotes(repo, notes string) error {
_, err := run(repo, "update-ref", NotesRef, notes)
return err
}
+18
View File
@@ -55,6 +55,18 @@ func New(dataDir string, portFirst int, spa fs.FS) (*Server, error) {
return nil, err
}
projects, err := s.store.ListProjects(context.Background())
if err != nil {
s.store.Close()
return nil, err
}
for _, p := range projects {
err = git.ConfigureNotesFetch(p.RepoPath)
if err != nil {
slog.Error("configure notes fetch", "project", p.Id, "repo", p.RepoPath, "err", err)
}
}
s.agents = agents.New(s.store, filepath.Join(dataDir, "worktrees"), filepath.Join(dataDir, "leads"), filepath.Join(dataDir, "codex"))
err = s.agents.Recover(context.Background())
if err != nil {
@@ -198,6 +210,12 @@ func (s *Server) CreateProject(w http.ResponseWriter, r *http.Request) {
return
}
err = git.ConfigureNotesFetch(info.Root)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
p := api.Project{
Id: store.NewID(),
Name: req.Name,
+34
View File
@@ -866,6 +866,40 @@ func (s *Store) TaskEventsTail(ctx context.Context, taskID string, limit int) ([
return scanEvents(rows)
}
type Handoff struct {
SessionID string
At time.Time
Text string
}
func (s *Store) TaskHandoffs(ctx context.Context, taskID string) ([]Handoff, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT agent_session_id, created_at, coalesce(json_extract(payload, '$.result'), '') FROM events
WHERE task_id = ? AND kind = 'turn.ended' AND json_extract(payload, '$.handoff') = 1
ORDER BY seq
`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
handoffs := []Handoff{}
for rows.Next() {
h := Handoff{}
at := ""
err = rows.Scan(&h.SessionID, &at, &h.Text)
if err != nil {
return nil, err
}
h.At, err = parseTime(at)
if err != nil {
return nil, err
}
handoffs = append(handoffs, h)
}
return handoffs, rows.Err()
}
func (s *Store) LeadEvents(ctx context.Context, owner string, after int64, limit int) ([]api.Event, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT `+eventColumns+`