package operations import ( "encoding/json" "errors" "fmt" "time" "orchestra/internal/authz" "orchestra/internal/domain" "orchestra/internal/human" "orchestra/internal/registry" "orchestra/internal/store" ) // ErrForeignPullRequest rejects an observation that does not belong to the // task's own submission. Only the pull request bound in TaskSubmitted may move // that task, or one task's forge traffic could complete another. var ErrForeignPullRequest = errors.New("observation is for a different pull request") // ReflectSubmission reconciles one submitted pull request. // // It runs on its own, not behind Store.PreLease. An in-review task cannot be // leased, so a pre-lease hook could never observe the feedback that should make // it leasable again. That is the same shape of bug as reconciling only at // launch, one lifecycle stage later. func ReflectSubmission(s *store.Store, project registry.Project, taskID string, state human.PullRequestState, trust human.Trust) ([]domain.Event, error) { t, ok := s.Task(taskID) if !ok { return nil, domain.ErrNotFound } if t.Submission == nil { return nil, nil } if state.ID != t.Submission.PR.ID { return nil, fmt.Errorf("%w: %s is not %s", ErrForeignPullRequest, state.ID, t.Submission.PR.ID) } submittedAt, submissionEvent, ok := submissionRecord(s, t) if !ok { return nil, fmt.Errorf("%w: no submission event for task %s", domain.ErrInvalid, taskID) } switch state.State { case "merged": // Merge strategy decides what MergeSHA is, so completion rests on the // bound pull request having merged while carrying the submitted commit. if state.HeadSHA != t.Submission.ResultSHA { return nil, fmt.Errorf("%w: pull request %s carries %s, but %s was submitted", ErrForeignPullRequest, state.ID, state.HeadSHA, t.Submission.ResultSHA) } if t.State == domain.StateCompleted { return nil, nil } e, err := complete(s, t, submissionEvent, state) if err != nil { return nil, err } return []domain.Event{e}, nil case "closed": // Closed without a merge could mean abandoned, rejected, superseded, // or a misclick. Guessing would be worse than surfacing it. if t.State == domain.StateNeedsAttention || t.State != domain.StateInReview { return nil, nil } e, err := blockTask(s, t, domain.BlockReasonOperator, fmt.Sprintf("Pull request %s was closed without merging the submitted commit %s. Decide whether this task is abandoned, superseded, or should be resubmitted.", state.ID, t.Submission.ResultSHA), nil) if err != nil { return nil, err } return []domain.Event{e}, nil } if t.State != domain.StateInReview { // Already reopened, or never submitted into review. Nothing to do. return nil, nil } feedback := state.FeedbackAfter(t.Submission.PR.Provider, submittedAt, trust) if len(feedback) == 0 { return nil, nil } var recorded []domain.Event var decisionIDs []string for _, in := range feedback { if id, exists := s.DecisionForSource(in.Provider, in.ExternalID); exists { // Already imported. A repeated poll must not reopen the task twice // for the same comment. decisionIDs = append(decisionIDs, id) continue } e, id, err := recordDecision(s, t.ID, in) if err != nil { return recorded, err } recorded = append(recorded, e) decisionIDs = append(decisionIDs, id) t, _ = s.Task(t.ID) } if len(recorded) == 0 { // Every comment was already imported, so this poll changed nothing. return nil, nil } e, err := requestChanges(s, t, submissionEvent, decisionIDs) if err != nil { return recorded, err } recorded = append(recorded, e) // The work goes back to implementation, where a fresh gate and a fresh // review will be required because the commit will change. if _, err := AdvanceWorkPhase(s, project, t.ID, nil); err != nil { return recorded, err } return recorded, nil } // submissionRecord finds when the current submission happened, which is the // cutoff for "feedback on this submission". func submissionRecord(s *store.Store, t domain.Task) (time.Time, string, bool) { for i := len(s.Events(0)) - 1; i >= 0; i-- { e := s.Events(0)[i] if e.TaskID != t.ID || e.Type != domain.EventTaskSubmitted { continue } var p struct { ResultSHA string `json:"result_sha"` } if json.Unmarshal(e.Payload, &p) == nil && p.ResultSHA == t.Submission.ResultSHA { return e.At, e.ID, true } } return time.Time{}, "", false } func recordDecision(s *store.Store, taskID string, in human.Input) (domain.Event, string, error) { current, ok := s.Task(taskID) if !ok { return domain.Event{}, "", domain.ErrNotFound } id := domain.NewID() b, err := json.Marshal(map[string]any{ "decision_id": id, "kind": string(domain.HumanDecisionCorrection), "subject": "operator_instruction", "value": in.Body, "author": in.Author, "source": map[string]any{"provider": in.Provider, "external_id": in.ExternalID}, }) if err != nil { return domain.Event{}, "", err } at := in.At if at.IsZero() { at = time.Now().UTC() } e := domain.Event{ID: domain.NewID(), Type: domain.EventHumanDecisionRecorded, TaskID: taskID, Version: current.Version + 1, At: at, Payload: b, Surface: string(authz.System)} return e, id, s.Append(e) } func requestChanges(s *store.Store, t domain.Task, submissionEvent string, decisionIDs []string) (domain.Event, error) { current, _ := s.Task(t.ID) b, err := json.Marshal(map[string]any{ "submission_event": submissionEvent, "submitted_sha": t.Submission.ResultSHA, "decision_ids": decisionIDs, }) if err != nil { return domain.Event{}, err } e := domain.Event{ID: domain.NewID(), Type: domain.EventTaskChangesRequested, TaskID: t.ID, Version: current.Version + 1, Payload: b, Surface: string(authz.System)} return e, s.Append(e) } func complete(s *store.Store, t domain.Task, submissionEvent string, state human.PullRequestState) (domain.Event, error) { receipt := domain.CompletionReceipt{ SubmissionRef: submissionEvent, PR: t.Submission.PR, SubmittedSHA: t.Submission.ResultSHA, MergeSHA: state.MergeSHA, MergedAt: state.MergedAt, } if receipt.MergedAt.IsZero() { receipt.MergedAt = time.Now().UTC() } sealed, err := json.Marshal(receipt) if err != nil { return domain.Event{}, err } ref, err := s.PutArtifact(sealed) if err != nil { return domain.Event{}, err } var asMap map[string]any if err := json.Unmarshal(sealed, &asMap); err != nil { return domain.Event{}, err } current, _ := s.Task(t.ID) b, err := json.Marshal(map[string]any{"report_ref": ref, "receipt": asMap}) if err != nil { return domain.Event{}, err } e := domain.Event{ID: domain.NewID(), Type: "TaskCompleted", TaskID: t.ID, Version: current.Version + 1, Payload: b, Surface: string(authz.System)} return e, s.Append(e) }