Record merge progress durably and finish pushed merges at startup
This commit is contained in:
@@ -1,7 +1,5 @@
|
|||||||
# Bugs
|
# Bugs
|
||||||
|
|
||||||
- Merge finalization keeps no durable progress; cleanup runs before the task is closed
|
|
||||||
`internal/agents/mergein.go:303–324` runs push → notes → fast-forward → `RemoveTask` → `MergeTask`. A crash or error after the push leaves an active task with no worktree; a retried Merge fails in `Snapshot`, or squashes to no change and closes with an empty `merge_commit`. Have the `finalizing` marker record the squash commit; on startup, a recorded commit that is an ancestor of origin/<default> finishes notes, fast-forward, removal and MergeTask idempotently, otherwise the marker is cleared and an error is filed.
|
|
||||||
- Codex mid-turn delivery deletes input before the steer is accepted
|
- Codex mid-turn delivery deletes input before the steer is accepted
|
||||||
Queued inputs are deleted or marked delivered and recorded as `user.message` (`internal/agents/queue.go:119`, `:158`; `internal/agents/inbox.go:404`) before `Steer`, whose error is only logged (`internal/agents/codex.go:167–174`). Return the consumed ids from `deliverMidTurn`; when Steer fails, re-insert the inputs in order and clear `delivered_at` so the turn-end Deliver sends them.
|
Queued inputs are deleted or marked delivered and recorded as `user.message` (`internal/agents/queue.go:119`, `:158`; `internal/agents/inbox.go:404`) before `Steer`, whose error is only logged (`internal/agents/codex.go:167–174`). Return the consumed ids from `deliverMidTurn`; when Steer fails, re-insert the inputs in order and clear `delivered_at` so the turn-end Deliver sends them.
|
||||||
- Inbox answer validation isn't atomic with resolving the question
|
- Inbox answer validation isn't atomic with resolving the question
|
||||||
|
|||||||
+1
-1
@@ -69,7 +69,7 @@ A task's `merge_title` is the title of the squash commit Merge makes. It is the
|
|||||||
|
|
||||||
A task's participants are the users who put a message bubble with their name in its transcript, derived from its events (`Store.Participants`): the actors of its `user.message` events, joined to `users` so `byre` and `agent` drop out. That covers messages sent to the agent and inbox answers, whose delivery message carries the name of the first responder in it (`Deliver` in `internal/agents/inbox.go`); dismissing an item puts nothing in the transcript and doesn't count. A fork's participants also include the user who forked it, the actor of its `task.created` event (whose payload carries `forked_from`), whose "forked this task" note opens its transcript. They are ordered by their first such event. `GET /api/tasks/{id}/co-authors` returns the participants other than the caller, with their git name and email from `PUT /api/me` (empty for a user who hasn't set one). The Merge confirm fetches it and lists `Name <email>` for each co-author, and separately those with no git identity, who get no trailer.
|
A task's participants are the users who put a message bubble with their name in its transcript, derived from its events (`Store.Participants`): the actors of its `user.message` events, joined to `users` so `byre` and `agent` drop out. That covers messages sent to the agent and inbox answers, whose delivery message carries the name of the first responder in it (`Deliver` in `internal/agents/inbox.go`); dismissing an item puts nothing in the transcript and doesn't count. A fork's participants also include the user who forked it, the actor of its `task.created` event (whose payload carries `forked_from`), whose "forked this task" note opens its transcript. They are ordered by their first such event. `GET /api/tasks/{id}/co-authors` returns the participants other than the caller, with their git name and email from `PUT /api/me` (empty for a user who hasn't set one). The Merge confirm fetches it and lists `Name <email>` for each co-author, and separately those with no git identity, who get no trailer.
|
||||||
|
|
||||||
`POST /api/tasks/{id}/merge` starts the same turn, with the prompt's closing invitation to revise the title left out since the human just confirmed it and a request to call `set_task_summary` in its place, and records in `tasks.merging_by` that the task is to be merged by the requesting user (emitting `task.merge_started` with that user as actor, not drawn), who must have a git identity (400 otherwise). Every turn end of the task's session reads and clears it. It survives a byre restart during the turn: startup resumes the session with the restart notice, and the merge happens when that resumed turn ends. When that turn ends normally (not interrupted, not an error), the transaction that ends it (`Store.EndTurn`) moves the login from `merging_by` to `tasks.finalizing`, so the task stays **Merging** while byre squashes, pushes and closes it. While `finalizing` is set, the task's implementer session can't start a turn (a message or Sync is a 409; the store refuses the `running` transition in `transitionSession`), Discard and merging into a parent are refused as merging, `set_merge_title` is refused, and the task can't get new subtasks (`ErrParentNotOpen`). Closing the task clears it; a failed squash or push clears it (emitting `task.merge_ended`) before filing the error, so the queued inbox responses can be delivered. A byre restart while it is set clears it at startup (`recoverFinalizing` in `internal/agents/mergein.go`) and files an error on the task saying the merge may not have been pushed and the worktree may be gone, since nothing records how far it got. `sqlite3 byre.db "SELECT id, finalizing FROM tasks WHERE finalizing != ''"` lists merges being finished. Then byre squashes the worktree, now containing the default branch, onto it with `git merge-tree --merge-base`, makes one commit titled with the task's `merge_title` with `git commit-tree`, authored and committed by the merging user with no agent attribution, its body one `Co-authored-by: Name <email>` trailer per co-author with a git identity (`squashMessage` in `internal/agents/mergein.go`; the co-authors are computed when the squash runs, relative to the user who clicked Merge), and pushes that commit to the default branch on the `origin` remote of the project's checkout together with its note (below), in one atomic push (`git push --atomic --porcelain origin <commit>:refs/heads/<default branch> <notes commit>:refs/notes/byre`, run in the checkout), so either both land or neither does. Only once the push is accepted does it point the checkout's `refs/notes/byre` at the notes commit, run `git merge --ff-only` in the project's checkout (which must have the default branch checked out) and close the task; the `task.merged` event carries the commit and `pushed` (`origin/<default branch>`), and the transcript shows both. When the merge changes nothing, no commit is made and nothing is pushed.
|
`POST /api/tasks/{id}/merge` starts the same turn, with the prompt's closing invitation to revise the title left out since the human just confirmed it and a request to call `set_task_summary` in its place, and records in `tasks.merging_by` that the task is to be merged by the requesting user (emitting `task.merge_started` with that user as actor, not drawn), who must have a git identity (400 otherwise). Every turn end of the task's session reads and clears it. It survives a byre restart during the turn: startup resumes the session with the restart notice, and the merge happens when that resumed turn ends. When that turn ends normally (not interrupted, not an error), the transaction that ends it (`Store.EndTurn`) moves the login from `merging_by` to `tasks.finalizing`, so the task stays **Merging** while byre squashes, pushes and closes it. While `finalizing` is set, the task's implementer session can't start a turn (a message or Sync is a 409; the store refuses the `running` transition in `transitionSession`), Discard and merging into a parent are refused as merging, `set_merge_title` is refused, and the task can't get new subtasks (`ErrParentNotOpen`). Closing the task clears it; a failed squash or push clears it (emitting `task.merge_ended`) before filing the error, so the queued inbox responses can be delivered. Just before pushing, byre records the squash commit and its notes commit in `tasks.finalizing_commit` and `tasks.finalizing_notes` (`Store.SetFinalizingCommit`); when the merge changes nothing, it records that in `tasks.finalizing_unchanged` (`Store.SetFinalizingUnchanged`) before removing the worktree. A push the remote rejected as non-fast-forward clears the marker as a failed push. Any other push error is ambiguous, since the remote may have taken the push before the connection dropped, so byre runs `git fetch origin` and checks whether the commit is an ancestor of `origin/<default branch>`: when it is, it finishes as if the push had succeeded; when it isn't, the push failed; when the check itself fails, the marker stays. A failure after the push was accepted (updating the checkout's notes, fast-forwarding it, removing the worktree, closing the task), or while closing an unchanged merge, keeps the marker and files an error saying byre finishes the merge when it next starts. At startup, `recoverFinalizing` (`internal/agents/mergein.go`) takes each task with `finalizing` set: an unchanged merge is finished by removing the worktree and closing the task with no commit; with a recorded commit, it runs `git fetch origin` and, when the commit is an ancestor of `origin/<default branch>`, finishes the merge (`closePushed`: advance the checkout's `refs/notes/byre` to the notes commit unless it already contains it, `git merge --ff-only`, remove the worktree, close the task), each step a no-op when already done. When the commit is not on the remote, or none was recorded, it clears the marker and files an error on the task saying to merge again; when the fetch, the ancestry check or a finishing step fails, it keeps the marker for the next start and files the error. `sqlite3 byre.db "SELECT id, finalizing, finalizing_commit FROM tasks WHERE finalizing != ''"` lists merges being finished. Then byre squashes the worktree, now containing the default branch, onto it with `git merge-tree --merge-base`, makes one commit titled with the task's `merge_title` with `git commit-tree`, authored and committed by the merging user with no agent attribution, its body one `Co-authored-by: Name <email>` trailer per co-author with a git identity (`squashMessage` in `internal/agents/mergein.go`; the co-authors are computed when the squash runs, relative to the user who clicked Merge), and pushes that commit to the default branch on the `origin` remote of the project's checkout together with its note (below), in one atomic push (`git push --atomic --porcelain origin <commit>:refs/heads/<default branch> <notes commit>:refs/notes/byre`, run in the checkout), so either both land or neither does. Only once the push is accepted does it point the checkout's `refs/notes/byre` at the notes commit, run `git merge --ff-only` in the project's checkout (which must have the default branch checked out) and close the task; the `task.merged` event carries the commit and `pushed` (`origin/<default branch>`), and the transcript shows both. When the merge changes nothing, no commit is made and nothing is pushed.
|
||||||
|
|
||||||
Before starting the turn, Merge runs `git fetch origin` (every configured refspec, so the notes too) and `git merge --ff-only origin/<default branch>` in the checkout, so the agent merges in what the remote has; the request waits for the fetch, and a failure (network, or a checkout with commits the remote lacks) is returned as the request's error. If the push is still refused, for example because someone pushed during the turn, or fails for any other reason, byre files the error in the inbox and the task stays active; clicking Merge again fetches and goes round once more. A refused push leaves the checkout's default branch where it was, since the squash commit was never moved onto it. If the squash fails, for example because the default branch moved again during the turn, byre also files an error and the task stays active. The agent calls `stop_merge` (`internal/agents/mergein.go`) with a `reason` when its conflict resolution took substantive changes: it clears `merging_by` and releases the project's merge at once, and records a `task.merge_stopped` event carrying the reason, which the transcript shows as a note. The turn then ends without a squash, the task stays active, its snapshot is reviewed, and the human clicks Merge again when satisfied. Called with no merge pending (a Sync turn, say), it changes nothing and tells the agent so. An interrupted or failed turn, an agent process that exits mid-turn, or a session that can't be resumed after a restart clears `merging_by` and leaves the task active without squashing; a turn that ends interrupted or with an empty final message also files an error in the inbox saying the merge didn't happen. A Claude `result` with `num_turns` 0, no `result` text and no error, outside an interrupt, isn't a turn end: the CLI emits one on its own, for example after a resume when it reports a background shell left over from the previous process, possibly after byre has already sent the next message. byre logs `ignored result with no model turns`, records it as an `agent.vendor` event, and keeps the turn running, so it can't take `merging_by` from the Merge turn that follows. Merge (either target, see "Subtasks") and Discard are refused (409, naming them) while the task has active subtasks, whoever asks: the button, the lead or an agent.
|
Before starting the turn, Merge runs `git fetch origin` (every configured refspec, so the notes too) and `git merge --ff-only origin/<default branch>` in the checkout, so the agent merges in what the remote has; the request waits for the fetch, and a failure (network, or a checkout with commits the remote lacks) is returned as the request's error. If the push is still refused, for example because someone pushed during the turn, or fails for any other reason, byre files the error in the inbox and the task stays active; clicking Merge again fetches and goes round once more. A refused push leaves the checkout's default branch where it was, since the squash commit was never moved onto it. If the squash fails, for example because the default branch moved again during the turn, byre also files an error and the task stays active. The agent calls `stop_merge` (`internal/agents/mergein.go`) with a `reason` when its conflict resolution took substantive changes: it clears `merging_by` and releases the project's merge at once, and records a `task.merge_stopped` event carrying the reason, which the transcript shows as a note. The turn then ends without a squash, the task stays active, its snapshot is reviewed, and the human clicks Merge again when satisfied. Called with no merge pending (a Sync turn, say), it changes nothing and tells the agent so. An interrupted or failed turn, an agent process that exits mid-turn, or a session that can't be resumed after a restart clears `merging_by` and leaves the task active without squashing; a turn that ends interrupted or with an empty final message also files an error in the inbox saying the merge didn't happen. A Claude `result` with `num_turns` 0, no `result` text and no error, outside an interrupt, isn't a turn end: the CLI emits one on its own, for example after a resume when it reports a background shell left over from the previous process, possibly after byre has already sent the next message. byre logs `ignored result with no model turns`, records it as an `agent.vendor` event, and keeps the turn running, so it can't take `merging_by` from the Merge turn that follows. Merge (either target, see "Subtasks") and Discard are refused (409, naming them) while the task has active subtasks, whoever asks: the button, the lead or an agent.
|
||||||
|
|
||||||
|
|||||||
+129
-32
@@ -136,26 +136,82 @@ func (m *Manager) recoverMerges(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) recoverFinalizing(ctx context.Context) error {
|
func (m *Manager) recoverFinalizing(ctx context.Context) error {
|
||||||
finalizing, err := m.store.FinalizingTasks(ctx)
|
finalizing, err := m.store.FinalizingMerges(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, t := range finalizing {
|
for _, f := range finalizing {
|
||||||
err = m.store.ClearFinalizing(ctx, t.Id)
|
ferr := m.resumeFinalizing(ctx, f)
|
||||||
|
if ferr == nil {
|
||||||
|
slog.Info("finished merge at startup", "task", f.Task.Id, "commit", f.Commit)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Error("finish merge at startup", "task", f.Task.Id, "err", ferr)
|
||||||
|
unfinished := &unfinishedMergeError{}
|
||||||
|
if !errors.As(ferr, &unfinished) {
|
||||||
|
err = m.store.ClearFinalizing(ctx, f.Task.Id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sessions, err := m.store.ListSessions(ctx, f.Task.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
sessions, err := m.store.ListSessions(ctx, t.Id)
|
m.fileTaskError(ctx, f.Task, sessions, "byre restarted while finishing this task's merge: "+ferr.Error()+".")
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
slog.Error("merge was finishing at startup", "task", t.Id)
|
|
||||||
m.fileTaskError(ctx, t, sessions, "byre restarted while finishing this task's merge, so it may not have been pushed, or the task's worktree may be gone. Check the default branch before merging again.")
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) resumeFinalizing(ctx context.Context, f store.FinalizingMerge) error {
|
||||||
|
project, err := m.store.GetProject(ctx, f.Task.ProjectId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if f.Unchanged {
|
||||||
|
return m.closeUnchanged(ctx, f.Task, project, f.By)
|
||||||
|
}
|
||||||
|
if f.Commit == "" {
|
||||||
|
return errors.New("nothing was pushed. Merge again to retry")
|
||||||
|
}
|
||||||
|
|
||||||
|
landed, err := pushLanded(project, f.Commit)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !landed {
|
||||||
|
return fmt.Errorf("its squash commit %s is not on %s/%s, so the push didn't land. Merge again to retry", f.Commit, git.Remote, project.DefaultBranch)
|
||||||
|
}
|
||||||
|
return m.closePushed(ctx, f.Task, project, f.Commit, f.Notes, f.By)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushLanded(project api.Project, commit string) (bool, error) {
|
||||||
|
target := git.Remote + "/" + project.DefaultBranch
|
||||||
|
err := git.Fetch(project.RepoPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, &unfinishedMergeError{fmt.Errorf("checking whether %s reached %s failed: %w", commit, target, err)}
|
||||||
|
}
|
||||||
|
landed, err := git.IsAncestor(project.RepoPath, commit, target)
|
||||||
|
if err != nil {
|
||||||
|
return false, &unfinishedMergeError{fmt.Errorf("checking whether %s reached %s failed: %w", commit, target, err)}
|
||||||
|
}
|
||||||
|
return landed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type unfinishedMergeError struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *unfinishedMergeError) Error() string {
|
||||||
|
return e.err.Error() + "; byre finishes the merge when it next starts"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *unfinishedMergeError) Unwrap() error {
|
||||||
|
return e.err
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) startHeldMerges(ctx context.Context) {
|
func (m *Manager) startHeldMerges(ctx context.Context) {
|
||||||
projects, err := m.store.ProjectsWithQueuedMerges(ctx)
|
projects, err := m.store.ProjectsWithQueuedMerges(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -274,6 +330,11 @@ func (m *Manager) finishMerge(l *live, login string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
slog.Error("finish merge", "task", l.taskID, "err", err)
|
slog.Error("finish merge", "task", l.taskID, "err", err)
|
||||||
|
unfinished := &unfinishedMergeError{}
|
||||||
|
if errors.As(err, &unfinished) {
|
||||||
|
m.fileError(l, "Merging the task failed: "+err.Error()+".")
|
||||||
|
return
|
||||||
|
}
|
||||||
cerr := m.store.ClearFinalizing(ctx, l.taskID)
|
cerr := m.store.ClearFinalizing(ctx, l.taskID)
|
||||||
if cerr != nil {
|
if cerr != nil {
|
||||||
slog.Error("clear merge finalizing", "task", l.taskID, "err", cerr)
|
slog.Error("clear merge finalizing", "task", l.taskID, "err", cerr)
|
||||||
@@ -315,38 +376,74 @@ func (m *Manager) squashAndClose(ctx context.Context, taskID, login string) erro
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
pushed := ""
|
if commit == "" {
|
||||||
if commit != "" {
|
err = m.store.SetFinalizingUnchanged(ctx, taskID)
|
||||||
note, err := m.mergeNote(ctx, task, login)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
notes, err := git.NoteCommit(project.RepoPath, commit, note, env)
|
return m.closeUnchanged(ctx, task, project, login)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = m.RemoveTask(ctx, task, project, login)
|
note, err := m.mergeNote(ctx, task, login)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
notes, err := git.NoteCommit(project.RepoPath, commit, note, env)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = m.store.SetFinalizingCommit(ctx, taskID, commit, notes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = git.Push(project.RepoPath, commit, project.DefaultBranch, notes)
|
||||||
|
if errors.Is(err, git.ErrRemoteMoved) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
landed, lerr := pushLanded(project, commit)
|
||||||
|
if lerr != nil {
|
||||||
|
return &unfinishedMergeError{fmt.Errorf("%w, and %w", err, errors.Unwrap(lerr))}
|
||||||
|
}
|
||||||
|
if !landed {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
slog.Warn("push reported failure but landed", "task", taskID, "commit", commit, "err", err)
|
||||||
|
}
|
||||||
|
return m.closePushed(ctx, task, project, commit, notes, login)
|
||||||
|
}
|
||||||
|
|
||||||
_, err = m.store.MergeTask(ctx, taskID, commit, pushed, login)
|
func (m *Manager) closeUnchanged(ctx context.Context, task api.Task, project api.Project, login string) error {
|
||||||
return err
|
err := m.RemoveTask(ctx, task, project, login)
|
||||||
|
if err != nil {
|
||||||
|
return &unfinishedMergeError{fmt.Errorf("the merge changed nothing, but removing the task's worktree failed: %w", err)}
|
||||||
|
}
|
||||||
|
_, err = m.store.MergeTask(ctx, task.Id, "", "", login)
|
||||||
|
if err != nil {
|
||||||
|
return &unfinishedMergeError{fmt.Errorf("the merge changed nothing, but closing the task failed: %w", err)}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) closePushed(ctx context.Context, task api.Task, project api.Project, commit, notes, login string) error {
|
||||||
|
pushed := git.Remote + "/" + project.DefaultBranch
|
||||||
|
err := git.AdvanceNotes(project.RepoPath, notes)
|
||||||
|
if err != nil {
|
||||||
|
return &unfinishedMergeError{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 &unfinishedMergeError{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 &unfinishedMergeError{fmt.Errorf("pushed %s to %s, but removing the task's worktree failed: %w", commit, pushed, err)}
|
||||||
|
}
|
||||||
|
_, err = m.store.MergeTask(ctx, task.Id, commit, pushed, login)
|
||||||
|
if err != nil {
|
||||||
|
return &unfinishedMergeError{fmt.Errorf("pushed %s to %s, but closing the task failed: %w", commit, pushed, err)}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) CoAuthors(ctx context.Context, taskID, login string) ([]api.Participant, error) {
|
func (m *Manager) CoAuthors(ctx context.Context, taskID, login string) ([]api.Participant, error) {
|
||||||
|
|||||||
+6
-1
@@ -251,8 +251,13 @@ func FastForward(repo, rev string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchFastForward(repo, branch string) error {
|
func Fetch(repo string) error {
|
||||||
_, err := run(repo, "fetch", Remote)
|
_, err := run(repo, "fetch", Remote)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchFastForward(repo, branch string) error {
|
||||||
|
err := Fetch(repo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-2
@@ -69,7 +69,23 @@ func NoteCommit(repo, commit, note string, env []string) (string, error) {
|
|||||||
return runEnv(repo, env, append(commitArgs, tree)...)
|
return runEnv(repo, env, append(commitArgs, tree)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetNotes(repo, notes string) error {
|
func AdvanceNotes(repo, notes string) error {
|
||||||
_, err := run(repo, "update-ref", NotesRef, notes)
|
current, 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)
|
||||||
|
}
|
||||||
|
if code == 0 {
|
||||||
|
contained, err := IsAncestor(repo, notes, current)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if contained {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = run(repo, "update-ref", NotesRef, notes)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-4
@@ -452,6 +452,10 @@ ALTER TABLE tasks ADD COLUMN finalizing TEXT NOT NULL DEFAULT '';
|
|||||||
ALTER TABLE tasks ADD COLUMN state TEXT NOT NULL DEFAULT '';
|
ALTER TABLE tasks ADD COLUMN state TEXT NOT NULL DEFAULT '';
|
||||||
ALTER TABLE tasks ADD COLUMN review_requests INTEGER NOT NULL DEFAULT 0;
|
ALTER TABLE tasks ADD COLUMN review_requests INTEGER NOT NULL DEFAULT 0;
|
||||||
UPDATE tasks SET state = ` + taskStateExpr + `;
|
UPDATE tasks SET state = ` + taskStateExpr + `;
|
||||||
|
`}, {"add-task-finalizing-commit", `
|
||||||
|
ALTER TABLE tasks ADD COLUMN finalizing_commit TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE tasks ADD COLUMN finalizing_notes TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE tasks ADD COLUMN finalizing_unchanged INTEGER NOT NULL DEFAULT 0;
|
||||||
`}}
|
`}}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -874,7 +878,7 @@ func (s *Store) TakeMergingBy(ctx context.Context, id string) (string, error) {
|
|||||||
func (s *Store) ClearFinalizing(ctx context.Context, id string) error {
|
func (s *Store) ClearFinalizing(ctx context.Context, id string) error {
|
||||||
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
||||||
projectID := ""
|
projectID := ""
|
||||||
err := tx.QueryRowContext(ctx, `UPDATE tasks SET finalizing = '' WHERE id = ? AND finalizing != '' RETURNING project_id`, id).Scan(&projectID)
|
err := tx.QueryRowContext(ctx, `UPDATE tasks SET finalizing = '', finalizing_commit = '', finalizing_notes = '', finalizing_unchanged = 0 WHERE id = ? AND finalizing != '' RETURNING project_id`, id).Scan(&projectID)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -885,8 +889,57 @@ func (s *Store) ClearFinalizing(ctx context.Context, id string) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) FinalizingTasks(ctx context.Context) ([]api.Task, error) {
|
func (s *Store) SetFinalizingCommit(ctx context.Context, id, commit, notes string) error {
|
||||||
return s.queryTasks(ctx, `SELECT `+taskColumns+` FROM tasks WHERE status = ? AND finalizing != ''`, string(api.TaskStatusActive))
|
return s.setFinalizing(ctx, id, commit, notes, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetFinalizingUnchanged(ctx context.Context, id string) error {
|
||||||
|
return s.setFinalizing(ctx, id, "", "", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) setFinalizing(ctx context.Context, id, commit, notes string, unchanged bool) error {
|
||||||
|
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
||||||
|
res, err := tx.ExecContext(ctx, `UPDATE tasks SET finalizing_commit = ?, finalizing_notes = ?, finalizing_unchanged = ? WHERE id = ? AND status = ? AND finalizing != ''`,
|
||||||
|
commit, notes, unchanged, id, string(api.TaskStatusActive))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type FinalizingMerge struct {
|
||||||
|
Task api.Task
|
||||||
|
By string
|
||||||
|
Commit string
|
||||||
|
Notes string
|
||||||
|
Unchanged bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) FinalizingMerges(ctx context.Context) ([]FinalizingMerge, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT `+taskColumns+`, finalizing, finalizing_commit, finalizing_notes, finalizing_unchanged FROM tasks WHERE status = ? AND finalizing != ''`, string(api.TaskStatusActive))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
merges := []FinalizingMerge{}
|
||||||
|
for rows.Next() {
|
||||||
|
f := FinalizingMerge{}
|
||||||
|
f.Task, err = scanTask(trailingScanner{rows, []any{&f.By, &f.Commit, &f.Notes, &f.Unchanged}})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
merges = append(merges, f)
|
||||||
|
}
|
||||||
|
return merges, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) MergingTasks(ctx context.Context) ([]api.Task, error) {
|
func (s *Store) MergingTasks(ctx context.Context) ([]api.Task, error) {
|
||||||
@@ -1107,7 +1160,7 @@ func (s *Store) DiscardTask(ctx context.Context, id, actor string) (api.Task, er
|
|||||||
|
|
||||||
func (s *Store) closeTask(ctx context.Context, id string, status api.TaskStatus, commit, actor, kind string, payload map[string]any) (api.Task, error) {
|
func (s *Store) closeTask(ctx context.Context, id string, status api.TaskStatus, commit, actor, kind string, payload map[string]any) (api.Task, error) {
|
||||||
err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
||||||
res, err := tx.ExecContext(ctx, `UPDATE tasks SET status = ?, closed_at = ?, merge_commit = ?, finalizing = '' WHERE id = ? AND status = ?`,
|
res, err := tx.ExecContext(ctx, `UPDATE tasks SET status = ?, closed_at = ?, merge_commit = ?, finalizing = '', finalizing_commit = '', finalizing_notes = '', finalizing_unchanged = 0 WHERE id = ? AND status = ?`,
|
||||||
string(status), now(), commit, id, string(api.TaskStatusActive))
|
string(status), now(), commit, id, string(api.TaskStatusActive))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
Reference in New Issue
Block a user