mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 17:36:45 +08:00
* feat(wakeups): add rule deadlines and expose the child-done system rule (MUL-7680)
Wakeups can now end: an absolute deadline (expires_at) or a relative wait
(expires_in_seconds) that restarts when the rule is re-enabled. When the
deadline passes first, the scheduler ends the rule; event rules with
on_timeout=wake run the target once with a wakeup.timeout fact. A timed-out
rule keeps disabled_at NULL so its timeout run stays claimable.
The implicit "wake the parent's assignee when a stage of sub-issues
finishes" behavior is now described by GET /api/issues/{id}/system-wakeups
and can be turned off or given a supplementary instruction per issue via
PUT /api/issues/{id}/system-wakeups/child_done. Rule reads fail open to the
previous behavior.
List responses add the creator (member or agent) and the new expiry fields.
The CLI gains --expires-in, --expires-at and --on-timeout.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* feat(issues): let members create wakeups and show the child-done system rule (MUL-7680)
The issue sidebar's Wakeups section is always available on open issues and
gains a New wakeup popover. Members pick a condition (a time, a recurring
check with an end date, someone's reply, an agent's run ending, or raw
events), the agent to wake, the instruction, how often it fires, how long
to wait and what happens on timeout. It posts the same configuration agents
create with the CLI.
The parent's child-done wake appears as a System rule with its stage,
remaining sub-issues and target, a per-issue toggle and a supplementary
instruction. Rows show when a rule ends, timed-out rules read as such, and
details name who created the rule. Rule titles wrap to two lines instead of
truncating.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* docs(wakeups): document deadlines, member-created rules and the system rule (MUL-7680)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* feat(wakeups): platform conditions, runaway protection and check-ins (MUL-7680)
Conditions: a wakeup can now wait for a fact the platform checks itself
(a status, assignee, label or property value; sub-issues or one stage
finishing; a linked PR's checks finishing or merging; another issue's
status). Related events only prompt an early evaluation; the rule wakes
its agent when the predicate becomes true, and a repeating rule fires
again only after it turned false or its facts changed. Finished PR checks
from before registration are ignored.
Runaway protection: repeating event rules stop after max_fires runs
(default 20), a rule pauses when its causal chain passes through it a
third time without a person in between, or when it starts 12 runs in an
hour. The reason is stored and shown.
Also: silent check-ins for every/cron checks (the run then posts no
fallback comment), wake now, delete, a per-rule run history, paused-rule
lists, timeline entries for created/triggered/timed-out/paused/check-in,
and source, paused scope, 7-day runs and child-done system rows in the
workspace list. The CLI gains --until-* conditions, --max-fires and the
trigger/delete/checkin/runs commands; the brief and wakeup prompt state
the check-in exception.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* feat(issues): record child-done transitions in the status write (MUL-7680)
The child-done system rule ran after the status write committed and gave
up on any error, so a crash, deploy or transient failure lost the
parent's wake. A trigger now records each child's move into a closed
status in the writing transaction, for every writer. The request that
wrote it processes the rows right away and a scheduler sweep retries
anything left over; claims make the two paths process a transition
once.
The rule also follows a workspace default (settings key
system_wakeup_child_done) until an issue sets its own, the per-issue
update accepts partial bodies, and each trigger adds a timeline entry
that names its system comment.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* feat(views): wakeup conditions, history, timeline and workspace list (MUL-7680)
Issue sidebar: members can create the platform-checked conditions (a field
reaching a value, sub-issues or a stage finishing, a linked PR's CI or merge,
another issue's status) and cap how often a repeating wait fires. Rule
details show the fire count, why the platform paused a rule, the latest runs
with silent-check notes, and offer wake now and delete.
The issue header says what the issue is waiting for ("Emacs 在等 Jiayuan 回复
+2") and opens the Wakeups section. Board and list cards use the same short
sentence, or flag a paused rule. Wakeup runs read as "由唤醒触发 · <condition>"
in the execution log, transcript and on the comments they post.
The timeline shows created, triggered, timed-out, paused and check-in entries
with the rule they came from; consecutive check-ins merge, and the child-done
entry stands in for its system comment, which it can reveal.
Automation → Issue wakeups adds a source column and filter, 7-day runs, a
paused scope with a banner, the child-done system rule on each waiting parent,
and "New wakeup" with an issue picker. Settings → Issue statuses sets the
system rule's workspace default. Copy in all five languages.
The server records the watched issue's identifier in other-issue conditions
and the rule's creator in timeline snapshots, and returns board summaries with
their condition.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* docs(wakeups): document conditions, runaway protection and check-ins (MUL-7680)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* fix(wakeups): list system rules without a revision and localize statuses (MUL-7680)
System rule rows returned revision 0, which clients reject as a rule
revision; they now return null, and a list row with an invalid revision no
longer fails the whole page. Built-in statuses in conditions and the status
picker read in the viewer's language. Adds a browser test for a condition
created from the form, the header summary, the scheduler waking the agent
and the workspace list.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* fix(views): say why a rule paused without repeating "paused" (MUL-7680)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* fix(i18n): use the product's task terms in wakeup copy (MUL-7680)
ja, ko and fr wakeup strings said イシュー / 이슈 / ticket; the product term
for an issue in those languages is タスク / 태스크 / tâche (and sub-tasks
accordingly), per the conventions page. French strings are rewritten for
the feminine noun.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* test(views): pin the shortcut platform in the wakeup form test (MUL-7680)
The send shortcut is primary+Enter, which is Ctrl on Linux CI runners, so
pressing Cmd+Enter only submitted on macOS.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* fix(views): simplify the workspace wakeup list
Put the filters on one row: scope as a segmented control on the left,
source/trigger/agent and a search-as-you-type box on the right. "New
wakeup" moves to the page header, which drops the separate Search button.
Rows are single-line: the identifier sits before the title, frequency and
timezone move into the trigger tooltip, and selection, prompt edit and
the last run's transcript show on row hover or focus. The issue column
takes the remaining width and truncates, so the table fits the page.
Also fix creating from the list: the issue picker closes itself right
after reporting the selection, and that close cancelled the flow before
the form could open.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* refactor(server): run the child_done rule as a system issue wakeup
The parent-assignee wake on closed sub-issue stages was its own code path:
a system comment, a mention-style run, an override table and endpoints.
It is now an ordinary issue_wakeup row with system_rule set, sharing the
condition, receipts, runaway protection, timeline entries and run model of
people's rules.
- One children_done evaluator: a stage wakes the assignee when it closes
while a later stage waits; the wrap-up waits for every sub-issue,
unstaged ones included. People's sub-issue conditions read the same set.
- issue_child_event records closing, reopening, re-parenting and restaging
for every writer; the request processes it right after commit and the
sweep retries. User sub-issue rules become immediate too.
- The target is resolved when it fires: an agent run, a squad leader run,
an inbox notification for a member, or only the timeline entry.
- A parked (backlog) parent catches up when it leaves backlog; a waiting
run of the same agent is joined instead of queuing a second one; the
hourly limit pauses a runaway rule. No system comment is posted.
- The run gets the issue's instruction, else the workspace's, else the
built-in one, plus each stage's counts and the next stage.
- Workspace defaults move to GET/PUT /api/system-wakeups (owners and
admins) and apply to every rule nobody customized; turning a rule on
records what already holds so old facts do not fire.
- Existing open parents get their rule from a one-time backfill.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* feat(views): workspace wakeup settings and sub-issue notifications
- Settings → Wakeups holds the sub-issue rule's workspace default and
default instruction; the toggle leaves Issue statuses.
- The issue's system rule shows who it reaches (a member is notified),
a platform pause, and the instruction it will use.
- Timeline entries say whether the assignee was woken, notified or joined
a waiting run. The folding of the old system comment is gone.
- Inbox renders the new children_done notification on web, desktop and
mobile.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* fix(server): stop wakeups from repeating their agent's runs
Runs are serialized per issue and agent, so a wakeup never ran beside
another run of its agent, but it queued behind one and repeated it. Each
rule now checks two things before it starts a run:
- Acknowledged: every input came from the agent itself. Its own comments
and issue changes never wake it, and a condition its own unfinished run
on the issue satisfied does not wake it when the platform or the agent
set the rule up. This fixes #8849: a coordinator closing its own stage is
not woken again while that run is going. A person's condition rule still
runs afterwards, since the running agent lacks its instruction.
- Merged: when a run of the agent is already waiting to start on the issue
(assignment, comment, another rule), the rule's instruction and facts
join that run (context.wakeup_joined, sent to the daemon as
wakeup_joined and rendered for every prompt kind) instead of queuing
another. Turning the rule off or replacing it withdraws what it joined.
Sub-issue changes now record the run that made them, and hints and
condition.met inputs carry those runs so a satisfied condition knows
whether the agent caused it. A rule that names the agent itself as the
actor keeps waking it on its own changes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* feat(views): show merged and acknowledged wakeups, hint assignee comment rules
- Timeline entries say when a wakeup joined the agent's waiting run or was
the agent's own doing, for people's rules and the sub-issue rule.
- The create form tells a member that a reply or comment rule for the
issue's agent assignee joins the run a comment already starts.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* fix(server): hand waiting wakeups to a run when it is claimed
A rule that fired while its agent had a run waiting used to write its
instruction into that run right away and consume its inputs. That let a
member's rule run under another member's identity, kept deleted rules'
instructions in the run, lost the inputs when the run was cancelled or
claimed by an older daemon, and skipped the fire cap and loop check.
Now the rule only waits for a run that runs as the same person its own
run would, keeping its inputs. When a daemon advertising
joined-wakeups-v1 claims that run, JoinWaitingWakeups rechecks each rule
(on, same agent and person, creator still allowed, not the agent's own
input, no loop), consumes its inputs, counts the fire toward max_fires,
adds its chain and records the merge. A re-claim drops entries of rules
turned off or changed since. Anything else leaves the rule its inputs to
start its own run.
A child_done rule created by the backfill or by processing a change now
treats sub-issues with unprocessed close events as still open, so the
backfill no longer swallows a close waiting for the sweep.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* fix(server): consume joined wakeup inputs only once the run starts
A claim used to consume the inputs of the rules that joined the run. If
that claim did not go through, the run went back to the queue carrying
inputs the rules no longer had: turning the sub-issue rule off on the
issue or for the workspace (which only changes enabled) did not remove
them from the next claim, and cancelling the run lost a once rule that
had already ended.
A claim now reserves the inputs for the run (receipt task_id, still
unprocessed). The rule's next dispatch settles them: a run that started
consumes them as a merged firing (once ends, max_fires counts and can
pause, timeline entry); a run that ended without starting gives them
back; one that has not started keeps them. Turning a rule off or
changing it discards its pending inputs, reserved ones included, and a
later claim of the same run rechecks every entry and drops the ones
without reserved inputs or no longer allowed to reach the run.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
682 lines
25 KiB
Go
682 lines
25 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/issuestatus"
|
|
"github.com/multica-ai/multica/server/internal/service"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/dbid"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
// PR merge automation (MUL-7429, MUL-7726).
|
|
//
|
|
// The whole rule, as users see it:
|
|
//
|
|
// - A PR whose title or branch name carries an issue identifier is linked to
|
|
// that issue, and so is one whose title or body puts it after a closing
|
|
// keyword ("Closes MUL-1"). A member can also link or remove a PR by hand.
|
|
// The keyword only links; it has no say in what the merge does.
|
|
// - The workspace picks what a merge does: move the issue to one status (Done
|
|
// by default, or any started or done status, custom ones included) or leave
|
|
// it alone. When every PR linked to an issue is merged, the issue moves to
|
|
// that status, unless someone turned the automation off for that one issue.
|
|
//
|
|
// The decision is evaluated only when a PR event touches the issue: a linked PR
|
|
// merges, a PR is linked, or a link is removed. Changing a setting, reopening an
|
|
// issue, or accepting it from Triage is not a PR event, so it never moves an
|
|
// issue by itself. That is what keeps a reopened issue open until new work
|
|
// lands, without any hidden per-issue switch.
|
|
|
|
// Decision states, shared with the issue page (see prAutoCompleteResponse).
|
|
// The names predate the configurable target and stay for installed clients.
|
|
const (
|
|
prAutoCompleteNone = "none" // no linked PR
|
|
prAutoCompleteWorkspaceDisabled = "workspace_disabled" // workspace leaves status alone
|
|
prAutoCompleteIssueDisabled = "issue_disabled" // turned off for this issue
|
|
prAutoCompleteTerminal = "terminal" // already done / cancelled
|
|
prAutoCompleteTriage = "triage" // not accepted yet
|
|
prAutoCompleteAtTarget = "at_target" // already in the target status
|
|
prAutoCompleteWaiting = "waiting" // some PRs still open / draft
|
|
prAutoCompleteNotMerged = "not_merged" // some PRs closed without merging
|
|
prAutoCompleteAllMerged = "all_merged" // every linked PR merged
|
|
)
|
|
|
|
// Workspace settings keys. pr_auto_complete_enabled is the switch desktop
|
|
// clients before MUL-7726 still show and write; prMergeStatusFromSettings
|
|
// reads it when pr_merge_status is absent, and reconcilePRMergeSettings keeps
|
|
// the two in step on every write.
|
|
const (
|
|
prMergeStatusKey = "pr_merge_status"
|
|
prAutoCompleteLegacyKey = "pr_auto_complete_enabled"
|
|
// prMergeStatusNone is the value that leaves an issue's status alone.
|
|
prMergeStatusNone = "none"
|
|
)
|
|
|
|
type prAutoCompleteDecision struct {
|
|
State string
|
|
// Target is the status a merge moves the issue to, or "" when the
|
|
// workspace leaves status alone (or its choice no longer resolves).
|
|
Target string
|
|
// PRs the state is about: still open for waiting, closed-unmerged for
|
|
// not_merged, every linked PR for all_merged.
|
|
PRs []db.ListIssueLinkedPullRequestStatesRow
|
|
// IssueDisabled is reported independently of State so the issue menu can
|
|
// show the right toggle label even when another state wins.
|
|
IssueDisabled bool
|
|
}
|
|
|
|
// prMergeStatusSetting reads the workspace's choice: "none", or the key of the
|
|
// status a merge moves an issue to. An unreadable settings blob means none:
|
|
// the setting authorizes a status write, so it must not default to the
|
|
// permissive side.
|
|
func prMergeStatusSetting(ws db.Workspace) string {
|
|
if len(ws.Settings) == 0 {
|
|
return issuestatus.Done
|
|
}
|
|
var s map[string]any
|
|
if err := json.Unmarshal(ws.Settings, &s); err != nil {
|
|
return prMergeStatusNone
|
|
}
|
|
return prMergeStatusFromSettings(s)
|
|
}
|
|
|
|
// prMergeStatusFromSettings is prMergeStatusSetting on decoded settings.
|
|
// Without pr_merge_status it follows the retired switch (a pod or client from
|
|
// before MUL-7726 may have written only that): off means none, else Done.
|
|
func prMergeStatusFromSettings(s map[string]any) string {
|
|
raw, ok := s[prMergeStatusKey]
|
|
if !ok || raw == nil {
|
|
if prAutoCompleteLegacyOn(s) {
|
|
return issuestatus.Done
|
|
}
|
|
return prMergeStatusNone
|
|
}
|
|
value, ok := raw.(string)
|
|
if !ok {
|
|
return prMergeStatusNone
|
|
}
|
|
if key := strings.ToLower(strings.TrimSpace(value)); key != "" {
|
|
return key
|
|
}
|
|
return prMergeStatusNone
|
|
}
|
|
|
|
// prAutoCompleteLegacyOn reads the retired switch the way releases before
|
|
// MUL-7726 did: absent means on, anything but a boolean means off.
|
|
func prAutoCompleteLegacyOn(s map[string]any) bool {
|
|
raw, ok := s[prAutoCompleteLegacyKey]
|
|
if !ok || raw == nil {
|
|
return true
|
|
}
|
|
on, ok := raw.(bool)
|
|
return ok && on
|
|
}
|
|
|
|
// reconcilePRMergeSettings adjusts a settings write so desktop clients from
|
|
// before MUL-7726 keep working. Those clients show and flip only the retired
|
|
// switch, and send back the rest of the settings they hold. A flip of that
|
|
// switch becomes a choice here (off → none, on → Done). Every write then
|
|
// mirrors the switch from the effective choice, so an old client shows it
|
|
// correctly. stored may be nil when the current settings could not be read.
|
|
func reconcilePRMergeSettings(stored, incoming map[string]any) {
|
|
if _, sent := incoming[prAutoCompleteLegacyKey]; sent && prAutoCompleteLegacyOn(incoming) != prAutoCompleteLegacyOn(stored) {
|
|
if prAutoCompleteLegacyOn(incoming) {
|
|
incoming[prMergeStatusKey] = issuestatus.Done
|
|
} else {
|
|
incoming[prMergeStatusKey] = prMergeStatusNone
|
|
}
|
|
}
|
|
if prMergeStatusFromSettings(incoming) == prMergeStatusNone {
|
|
incoming[prAutoCompleteLegacyKey] = false
|
|
} else {
|
|
delete(incoming, prAutoCompleteLegacyKey)
|
|
}
|
|
}
|
|
|
|
// resolvePRMergeTarget returns the status a merge moves issues to in this
|
|
// workspace, or "" to leave status alone. The chosen key must still name a
|
|
// live started or done status other than Blocked; anything else (an archived
|
|
// custom status, a failed catalog read) fails closed to no write. It shares
|
|
// the delivery's resolver, so a built-in target costs no catalog read.
|
|
func (h *Handler) resolvePRMergeTarget(ctx context.Context, ws db.Workspace, resolver *issuestatus.Resolver) string {
|
|
key := prMergeStatusSetting(ws)
|
|
if key == prMergeStatusNone || key == issuestatus.Blocked {
|
|
return ""
|
|
}
|
|
switch resolver.WritableCategory(ctx, h.issueStatusCatalog(), key) {
|
|
case issuestatus.CategoryStarted, issuestatus.CategoryDone:
|
|
return key
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// decidePRAutoComplete computes the decision for one issue. resolver may be
|
|
// shared across one delivery so a webhook touching many issues reads the
|
|
// workspace status catalog at most once; nil builds a fresh one.
|
|
func (h *Handler) decidePRAutoComplete(ctx context.Context, ws db.Workspace, issue db.Issue, resolver *issuestatus.Resolver) (prAutoCompleteDecision, error) {
|
|
prs, err := h.Queries.ListIssueLinkedPullRequestStates(ctx, issue.ID)
|
|
if err != nil {
|
|
return prAutoCompleteDecision{}, err
|
|
}
|
|
disabled, err := h.Queries.GetIssuePRAutoCompleteDisabled(ctx, issue.ID)
|
|
if err != nil {
|
|
return prAutoCompleteDecision{}, err
|
|
}
|
|
if resolver == nil {
|
|
resolver = issuestatus.NewResolver(issue.WorkspaceID)
|
|
}
|
|
d := prAutoCompleteDecision{IssueDisabled: disabled, Target: h.resolvePRMergeTarget(ctx, ws, resolver)}
|
|
if len(prs) == 0 {
|
|
d.State = prAutoCompleteNone
|
|
return d, nil
|
|
}
|
|
// An unresolvable custom key is returned unchanged and treated as not
|
|
// terminal, the same direction the previous merge gate took.
|
|
effective := resolver.Effective(ctx, h.issueStatusCatalog(), issue.Status)
|
|
// The guards that make a merge change nothing come first, so the issue page
|
|
// only speaks when a merge would move the issue.
|
|
switch {
|
|
case effective == issuestatus.Done || effective == issuestatus.Cancelled:
|
|
d.State = prAutoCompleteTerminal
|
|
return d, nil
|
|
case issue.TriageState.Valid:
|
|
d.State = prAutoCompleteTriage
|
|
return d, nil
|
|
case d.Target == "":
|
|
d.State = prAutoCompleteWorkspaceDisabled
|
|
return d, nil
|
|
case disabled:
|
|
d.State = prAutoCompleteIssueDisabled
|
|
return d, nil
|
|
case issue.Status == d.Target:
|
|
d.State = prAutoCompleteAtTarget
|
|
return d, nil
|
|
}
|
|
var open, closed []db.ListIssueLinkedPullRequestStatesRow
|
|
for _, pr := range prs {
|
|
switch pr.State {
|
|
case "open", "draft":
|
|
open = append(open, pr)
|
|
case "closed":
|
|
closed = append(closed, pr)
|
|
}
|
|
}
|
|
switch {
|
|
case len(open) > 0:
|
|
d.State, d.PRs = prAutoCompleteWaiting, open
|
|
case len(closed) > 0:
|
|
d.State, d.PRs = prAutoCompleteNotMerged, closed
|
|
default:
|
|
d.State, d.PRs = prAutoCompleteAllMerged, prs
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// maybeAutoCompleteIssue runs the decision for one issue after a PR event and
|
|
// moves it to the workspace's target status when every linked PR is merged.
|
|
// Safe to call for any issue: every guard lives in decidePRAutoComplete, and
|
|
// the status write is conditional on the status the decision saw, so
|
|
// concurrent merges move the issue once. It starts no agent run: a merge is not
|
|
// a person handing the issue to anyone.
|
|
func (h *Handler) maybeAutoCompleteIssue(ctx context.Context, workspaceID, issueID pgtype.UUID, resolver *issuestatus.Resolver) {
|
|
issue, err := h.Queries.GetIssueInWorkspace(ctx, db.GetIssueInWorkspaceParams{ID: issueID, WorkspaceID: workspaceID})
|
|
if err != nil {
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
slog.Warn("pr auto-complete: load issue failed", "err", err, "issue_id", uuidToString(issueID))
|
|
}
|
|
return
|
|
}
|
|
ws, err := h.Queries.GetWorkspace(ctx, workspaceID)
|
|
if err != nil {
|
|
slog.Warn("pr auto-complete: load workspace failed", "err", err, "workspace_id", uuidToString(workspaceID))
|
|
return
|
|
}
|
|
d, err := h.decidePRAutoComplete(ctx, ws, issue, resolver)
|
|
if err != nil {
|
|
slog.Warn("pr auto-complete: decision failed", "err", err, "issue_id", uuidToString(issueID))
|
|
return
|
|
}
|
|
if d.State != prAutoCompleteAllMerged {
|
|
return
|
|
}
|
|
|
|
tx, err := h.TxStarter.Begin(ctx)
|
|
if err != nil {
|
|
slog.Warn("pr auto-complete: begin failed", "err", err)
|
|
return
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
qtx := h.Queries.WithTx(tx)
|
|
updated, err := qtx.MoveIssueFromPullRequests(ctx, db.MoveIssueFromPullRequestsParams{
|
|
ID: issue.ID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
TargetStatus: d.Target,
|
|
ExpectedStatus: issue.Status,
|
|
})
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
// Someone changed the status since the decision; their write wins.
|
|
return
|
|
}
|
|
var cancelledWakeups []db.AgentTaskQueue
|
|
if err == nil {
|
|
// A no-op unless the target closes the issue.
|
|
cancelledWakeups, err = service.StopClosedIssueWakeups(ctx, qtx, updated)
|
|
}
|
|
if err == nil {
|
|
err = tx.Commit(ctx)
|
|
}
|
|
if err != nil {
|
|
slog.Warn("pr auto-complete: status write failed", "err", err, "issue_id", uuidToString(issueID))
|
|
return
|
|
}
|
|
h.broadcastCancelledWakeups(ctx, updated.WorkspaceID, cancelledWakeups)
|
|
// A merged PR is the most common way a sub-issue reaches done; the parent
|
|
// hears about it on the same path as a manual status change.
|
|
if updated.ParentIssueID.Valid {
|
|
h.processChildEvents(ctx, updated.ParentIssueID)
|
|
}
|
|
|
|
prefix := h.getIssuePrefix(ctx, issue.WorkspaceID)
|
|
resp := issueToResponse(updated, prefix)
|
|
h.fillStatusCategory(ctx, updated.WorkspaceID, &resp)
|
|
h.publish(protocol.EventIssueUpdated, uuidToString(workspaceID), "system", "", map[string]any{
|
|
"issue": resp,
|
|
"status_changed": true,
|
|
"prev_status": issue.Status,
|
|
"creator_type": issue.CreatorType,
|
|
"creator_id": uuidToString(issue.CreatorID),
|
|
"source": "pr_automation",
|
|
"pull_requests": prNumberList(d.PRs),
|
|
// Moving out of cancelled clears a duplicate mark (MUL-7349); carry
|
|
// both ends so the activity log and clients see the mark go.
|
|
"duplicate_of_issue_id": liveDuplicateMark(updated.Status, updated.DuplicateOfIssueID),
|
|
"prev_duplicate_of_issue_id": liveDuplicateMark(issue.Status, issue.DuplicateOfIssueID),
|
|
})
|
|
}
|
|
|
|
// prNumberList renders "#12, #15" for the activity entry.
|
|
func prNumberList(prs []db.ListIssueLinkedPullRequestStatesRow) string {
|
|
parts := make([]string, 0, len(prs))
|
|
for _, pr := range prs {
|
|
parts = append(parts, fmt.Sprintf("#%d", pr.PrNumber))
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
// ── Issue page read model ───────────────────────────────────────────────────
|
|
|
|
type prAutoCompleteResponse struct {
|
|
State string `json:"state"`
|
|
// PR ids the state refers to (see prAutoCompleteDecision.PRs).
|
|
PullRequestIDs []string `json:"pull_request_ids"`
|
|
IssueDisabled bool `json:"issue_disabled"`
|
|
// WorkspaceEnabled is true when a merge moves issues at all.
|
|
WorkspaceEnabled bool `json:"workspace_enabled"`
|
|
// TargetStatus is the status key a merge moves the issue to, or "none".
|
|
TargetStatus string `json:"target_status"`
|
|
}
|
|
|
|
func prAutoCompleteToResponse(d prAutoCompleteDecision) prAutoCompleteResponse {
|
|
ids := make([]string, 0, len(d.PRs))
|
|
for _, pr := range d.PRs {
|
|
ids = append(ids, uuidToString(pr.ID))
|
|
}
|
|
target := d.Target
|
|
if target == "" {
|
|
target = prMergeStatusNone
|
|
}
|
|
return prAutoCompleteResponse{
|
|
State: d.State,
|
|
PullRequestIDs: ids,
|
|
IssueDisabled: d.IssueDisabled,
|
|
WorkspaceEnabled: d.Target != "",
|
|
TargetStatus: target,
|
|
}
|
|
}
|
|
|
|
// prLinkSource explains how a PR came to be linked: "manual", or the text the
|
|
// webhook matched ("title" / "branch"). "auto" covers the rest: a closing
|
|
// keyword in the body (which is not stored) or a link whose text changed since.
|
|
func prLinkSource(linkedByType, identifier, title, branch string) string {
|
|
if linkedByType == "member" {
|
|
return "manual"
|
|
}
|
|
if containsIdentifier(title, identifier) {
|
|
return "title"
|
|
}
|
|
if containsIdentifier(branch, identifier) {
|
|
return "branch"
|
|
}
|
|
return "auto"
|
|
}
|
|
|
|
func containsIdentifier(text, identifier string) bool {
|
|
for _, id := range extractIdentifiers(text) {
|
|
if strings.EqualFold(id, identifier) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ── Manual link / unlink ────────────────────────────────────────────────────
|
|
|
|
type LinkIssuePullRequestRequest struct {
|
|
// Either a pasted PR URL or the id of an already-listed PR (undo of a
|
|
// removal).
|
|
URL string `json:"url"`
|
|
PullRequestID string `json:"pull_request_id"`
|
|
}
|
|
|
|
// prURLPattern keeps scheme://host/owner/repo/(pull|pulls|-/merge_requests)/N
|
|
// and drops anything after it (/files, ?query, #fragment).
|
|
var prURLPattern = regexp.MustCompile(`^(https?://[^/?#]+/.+?/(?:pull|pulls|merge_requests)/\d+)(?:[/?#].*)?$`)
|
|
|
|
func normalizePullRequestURL(raw string) (string, bool) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return "", false
|
|
}
|
|
if !strings.Contains(raw, "://") {
|
|
raw = "https://" + raw
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.Host == "" {
|
|
return "", false
|
|
}
|
|
m := prURLPattern.FindStringSubmatch(u.Scheme + "://" + u.Host + u.EscapedPath())
|
|
if m == nil {
|
|
return "", false
|
|
}
|
|
return strings.ToLower(strings.TrimRight(m[1], "/")), true
|
|
}
|
|
|
|
// linkedPR is a PR resolved from either provider table.
|
|
type linkedPR struct {
|
|
ID pgtype.UUID
|
|
Provider string // "github" or the VCS provider name
|
|
Number int32
|
|
github bool
|
|
}
|
|
|
|
func (h *Handler) findPullRequestForLink(ctx context.Context, workspaceID pgtype.UUID, req LinkIssuePullRequestRequest) (linkedPR, bool, error) {
|
|
if req.PullRequestID != "" {
|
|
id, err := util.ParseUUID(req.PullRequestID)
|
|
if err != nil {
|
|
return linkedPR{}, false, nil
|
|
}
|
|
return h.findPullRequestByID(ctx, workspaceID, id)
|
|
}
|
|
normalized, ok := normalizePullRequestURL(req.URL)
|
|
if !ok {
|
|
return linkedPR{}, false, nil
|
|
}
|
|
if pr, err := h.Queries.FindGitHubPullRequestByURL(ctx, db.FindGitHubPullRequestByURLParams{WorkspaceID: workspaceID, HtmlUrl: normalized}); err == nil {
|
|
return linkedPR{ID: pr.ID, Provider: "github", Number: pr.PrNumber, github: true}, true, nil
|
|
} else if !errors.Is(err, pgx.ErrNoRows) {
|
|
return linkedPR{}, false, err
|
|
}
|
|
if pr, err := h.Queries.FindVCSPullRequestByURL(ctx, db.FindVCSPullRequestByURLParams{WorkspaceID: workspaceID, HtmlUrl: normalized}); err == nil {
|
|
return linkedPR{ID: pr.ID, Provider: pr.Provider, Number: pr.PrNumber}, true, nil
|
|
} else if !errors.Is(err, pgx.ErrNoRows) {
|
|
return linkedPR{}, false, err
|
|
}
|
|
return linkedPR{}, false, nil
|
|
}
|
|
|
|
func (h *Handler) findPullRequestByID(ctx context.Context, workspaceID, id pgtype.UUID) (linkedPR, bool, error) {
|
|
if pr, err := h.Queries.GetGitHubPullRequestInWorkspace(ctx, db.GetGitHubPullRequestInWorkspaceParams{ID: id, WorkspaceID: workspaceID}); err == nil {
|
|
return linkedPR{ID: pr.ID, Provider: "github", Number: pr.PrNumber, github: true}, true, nil
|
|
} else if !errors.Is(err, pgx.ErrNoRows) {
|
|
return linkedPR{}, false, err
|
|
}
|
|
if pr, err := h.Queries.GetVCSPullRequestInWorkspace(ctx, db.GetVCSPullRequestInWorkspaceParams{ID: id, WorkspaceID: workspaceID}); err == nil {
|
|
return linkedPR{ID: pr.ID, Provider: pr.Provider, Number: pr.PrNumber}, true, nil
|
|
} else if !errors.Is(err, pgx.ErrNoRows) {
|
|
return linkedPR{}, false, err
|
|
}
|
|
return linkedPR{}, false, nil
|
|
}
|
|
|
|
// LinkIssuePullRequest (POST /api/issues/{id}/pull-requests) links a PR the
|
|
// workspace already mirrors. Linking is a PR event for the issue, so it can
|
|
// complete the issue when every linked PR is merged and one of them closes it.
|
|
// A manual link carries no close intent of its own; the PR text decides that.
|
|
func (h *Handler) LinkIssuePullRequest(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
issue, ok := h.loadIssueForUser(w, r, chi.URLParam(r, "id"))
|
|
if !ok {
|
|
return
|
|
}
|
|
var req LinkIssuePullRequestRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.URL) == "" && req.PullRequestID == "" {
|
|
writeError(w, http.StatusBadRequest, "url is required")
|
|
return
|
|
}
|
|
pr, found, err := h.findPullRequestForLink(r.Context(), issue.WorkspaceID, req)
|
|
if err != nil {
|
|
slog.Warn("link pull request: lookup failed", "err", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to link pull request")
|
|
return
|
|
}
|
|
if !found {
|
|
writeError(w, http.StatusNotFound, "pull request not found; it appears here once its repository is connected and the PR has been synced")
|
|
return
|
|
}
|
|
workspaceID := uuidToString(issue.WorkspaceID)
|
|
_, actorID := h.resolveActor(r, userID, workspaceID)
|
|
linkedBy := pgtype.UUID{}
|
|
if parsed, err := util.ParseUUID(actorID); err == nil {
|
|
linkedBy = parsed
|
|
}
|
|
var rows int64
|
|
if pr.github {
|
|
rows, err = h.Queries.LinkIssueToPullRequestManually(r.Context(), db.LinkIssueToPullRequestManuallyParams{
|
|
IssueID: issue.ID, PullRequestID: pr.ID, LinkedByID: linkedBy,
|
|
})
|
|
} else {
|
|
rows, err = h.Queries.LinkIssueToVCSPullRequestManually(r.Context(), db.LinkIssueToVCSPullRequestManuallyParams{
|
|
IssueID: issue.ID, PullRequestID: pr.ID, LinkedByID: linkedBy,
|
|
})
|
|
}
|
|
if err == nil {
|
|
err = h.Queries.DeletePullRequestExclusion(r.Context(), db.DeletePullRequestExclusionParams{IssueID: issue.ID, PullRequestID: pr.ID})
|
|
}
|
|
if err != nil {
|
|
slog.Warn("link pull request failed", "err", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to link pull request")
|
|
return
|
|
}
|
|
if rows > 0 {
|
|
h.maybeAutoCompleteIssue(r.Context(), issue.WorkspaceID, issue.ID, nil)
|
|
}
|
|
h.publishIssuePullRequestsChanged(workspaceID, issue.ID)
|
|
h.ListPullRequestsForIssue(w, r)
|
|
}
|
|
|
|
// UnlinkIssuePullRequest (DELETE /api/issues/{id}/pull-requests/{prId})
|
|
// removes a link and remembers the choice, so the next webhook for that PR does
|
|
// not link it again from its title or branch.
|
|
func (h *Handler) UnlinkIssuePullRequest(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
issue, ok := h.loadIssueForUser(w, r, chi.URLParam(r, "id"))
|
|
if !ok {
|
|
return
|
|
}
|
|
prID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "prId"), "pull request id")
|
|
if !ok {
|
|
return
|
|
}
|
|
pr, found, err := h.findPullRequestByID(r.Context(), issue.WorkspaceID, prID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to unlink pull request")
|
|
return
|
|
}
|
|
if !found {
|
|
writeError(w, http.StatusNotFound, "pull request not found")
|
|
return
|
|
}
|
|
workspaceID := uuidToString(issue.WorkspaceID)
|
|
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
|
excludedBy := pgtype.UUID{}
|
|
if parsed, err := util.ParseUUID(actorID); err == nil {
|
|
excludedBy = parsed
|
|
}
|
|
|
|
tx, err := h.TxStarter.Begin(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to unlink pull request")
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
qtx := h.Queries.WithTx(tx)
|
|
var rows int64
|
|
if pr.github {
|
|
rows, err = qtx.UnlinkIssueFromPullRequest(r.Context(), db.UnlinkIssueFromPullRequestParams{IssueID: issue.ID, PullRequestID: pr.ID})
|
|
} else {
|
|
rows, err = qtx.UnlinkIssueFromVCSPullRequest(r.Context(), db.UnlinkIssueFromVCSPullRequestParams{IssueID: issue.ID, PullRequestID: pr.ID})
|
|
}
|
|
if err == nil {
|
|
err = qtx.ExcludePullRequestFromIssue(r.Context(), db.ExcludePullRequestFromIssueParams{
|
|
IssueID: issue.ID,
|
|
PullRequestID: pr.ID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
ExcludedByType: strToText(actorType),
|
|
ExcludedByID: excludedBy,
|
|
})
|
|
}
|
|
if err == nil {
|
|
err = tx.Commit(r.Context())
|
|
}
|
|
if err != nil {
|
|
slog.Warn("unlink pull request failed", "err", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to unlink pull request")
|
|
return
|
|
}
|
|
if rows > 0 {
|
|
// Removing the last unmerged PR can leave only merged ones behind.
|
|
h.maybeAutoCompleteIssue(r.Context(), issue.WorkspaceID, issue.ID, nil)
|
|
}
|
|
h.publishIssuePullRequestsChanged(workspaceID, issue.ID)
|
|
h.ListPullRequestsForIssue(w, r)
|
|
}
|
|
|
|
type SetIssuePRAutoCompleteRequest struct {
|
|
Disabled *bool `json:"disabled"`
|
|
}
|
|
|
|
// SetIssuePRAutoComplete (PUT /api/issues/{id}/pr-auto-complete) turns PR
|
|
// auto-complete off (or back on) for one issue — for work that needs review or
|
|
// a release after the merge. Turning it back on does not complete the issue by
|
|
// itself; the next PR event does.
|
|
func (h *Handler) SetIssuePRAutoComplete(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
issue, ok := h.loadIssueForUser(w, r, chi.URLParam(r, "id"))
|
|
if !ok {
|
|
return
|
|
}
|
|
var req SetIssuePRAutoCompleteRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Disabled == nil {
|
|
writeError(w, http.StatusBadRequest, "disabled is required")
|
|
return
|
|
}
|
|
workspaceID := uuidToString(issue.WorkspaceID)
|
|
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
|
actorUUID := pgtype.UUID{}
|
|
if parsed, err := util.ParseUUID(actorID); err == nil {
|
|
actorUUID = parsed
|
|
}
|
|
before, err := h.Queries.GetIssuePRAutoCompleteDisabled(r.Context(), issue.ID)
|
|
if err == nil {
|
|
err = h.Queries.SetIssuePRAutoCompleteDisabled(r.Context(), db.SetIssuePRAutoCompleteDisabledParams{
|
|
IssueID: issue.ID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
AutoCompleteDisabled: *req.Disabled,
|
|
UpdatedByType: strToText(actorType),
|
|
UpdatedByID: actorUUID,
|
|
})
|
|
}
|
|
if err != nil {
|
|
slog.Warn("set issue pr auto-complete failed", "err", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to update pull request automation")
|
|
return
|
|
}
|
|
if before != *req.Disabled {
|
|
h.recordPRAutoCompleteActivity(r.Context(), issue, actorType, actorID, *req.Disabled)
|
|
}
|
|
h.publishIssuePullRequestsChanged(workspaceID, issue.ID)
|
|
h.ListPullRequestsForIssue(w, r)
|
|
}
|
|
|
|
func (h *Handler) recordPRAutoCompleteActivity(ctx context.Context, issue db.Issue, actorType, actorID string, disabled bool) {
|
|
details, _ := json.Marshal(map[string]bool{"disabled": disabled})
|
|
activity, err := h.Queries.CreateActivity(ctx, db.CreateActivityParams{
|
|
ID: dbid.NewV7(),
|
|
WorkspaceID: issue.WorkspaceID,
|
|
IssueID: issue.ID,
|
|
ActorType: strToText(actorType),
|
|
ActorID: optionalActorUUID(actorID),
|
|
Action: "pr_auto_complete_changed",
|
|
Details: details,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("record pr auto-complete activity failed", "err", err)
|
|
return
|
|
}
|
|
h.publish(protocol.EventActivityCreated, uuidToString(issue.WorkspaceID), actorType, actorID, map[string]any{
|
|
"issue_id": uuidToString(issue.ID),
|
|
"entry": map[string]any{
|
|
"type": "activity",
|
|
"id": uuidToString(activity.ID),
|
|
"actor_type": actorType,
|
|
"actor_id": actorID,
|
|
"action": activity.Action,
|
|
"details": json.RawMessage(details),
|
|
"created_at": timestampToString(activity.CreatedAt),
|
|
},
|
|
})
|
|
}
|
|
|
|
func optionalActorUUID(id string) pgtype.UUID {
|
|
parsed, err := util.ParseUUID(id)
|
|
if err != nil {
|
|
return pgtype.UUID{}
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
// publishIssuePullRequestsChanged makes open issue pages refetch their PR list.
|
|
func (h *Handler) publishIssuePullRequestsChanged(workspaceID string, issueID pgtype.UUID) {
|
|
h.publish(protocol.EventPullRequestUpdated, workspaceID, "system", "", map[string]any{
|
|
"issue_id": uuidToString(issueID),
|
|
"linked_issue_ids": []string{uuidToString(issueID)},
|
|
})
|
|
}
|