Files
multica/packages/views/projects/components/github-ref-field.tsx
Bohan Jiangandmultica-agent 9018df3efb feat(projects): set a repository's checkout ref from the UI (MUL-7504) (#8592)
* feat(projects): set a repository's checkout ref from the UI (MUL-7504)

A project that tracks one delivery line could already pin the branch its
tasks start from — `github_repo.resource_ref.ref` has been stored, sent to
the daemon and honored at checkout since #4467. The UI only ever displayed
it. Anyone working through the web, desktop or mobile app had to drop to
`multica project resource update --ref` to set something the interface
already showed as a property of the resource, and until they did, every
task silently started from the remote default branch.

This adds the missing input, on the three paths a repository is configured:

- Create-project modal: a "Branch, tag, or commit" field per selected repo,
  shown once a URL is entered so the common default-branch case still reads
  as one field.
- Resource panel: the same field in the attach form, plus a dialog on each
  attached repo — the row previously offered only delete.
- Mobile: the field in the attach sheet, and the ref rendered in the list,
  which never showed it at all.

Three details the shape of the existing code forced:

- The ref renders on its own line rather than appended to the repo label. A
  custom name took that slot, so naming a repo hid the one signal that its
  tasks do not start from the default branch — and mobile is the only client
  that collects a name.
- Edits spread the stored ref before overwriting. The server replaces
  resource_ref wholesale, so a payload carrying only `ref` is rejected for a
  missing URL.
- Pasting a `.../tree/<branch>` URL now splits into the two fields. Mobile's
  URL check accepted that whole string and stored it as the clone URL, so
  the gap was already producing targets that cannot be cloned.

Server-side, `ref` was stored with no validation beyond a trim, so a typo
survived to checkout and surfaced as a daemon 500 naming its local repo
cache, minutes later and to whoever ran the task rather than whoever typed
it. validateGitRef applies the shape rules from `git check-ref-format` plus
a length cap. Existence on the remote is deliberately still not checked:
the server cannot see the repository, and an offline daemon or a private
repo must not block saving configuration.

Free text rather than a branch picker, by design. Nothing in the product
can list a repository's branches today, and a picker could not express a
tag or a commit SHA anyway. A "list remote branches" endpoint remains the
follow-up #8572 proposes; it should stay an input aid, never the only way.

Co-authored-by: multica-agent <[email protected]>

* feat(projects): make the pinned branch the delivery target too (MUL-7504)

Pinning a repository's starting point only solved half the problem. Nothing
in Multica creates pull requests — `gh pr create` is the agent's own call,
and without `--base` it targets the repository's default branch. So a
project pinned to `release/2026-09` had its agents start in the right place
and then open pull requests into `main`, carrying every commit the release
line had that `main` lacked. Product review asked for the two halves to
move together, so the brief now states both.

The Repositories section names each repo's starting point and, when
anything is pinned, says to deliver back to it. The Project Context bullet
stops implying `--ref` is how you reach the configured revision — the
daemon already applied it, and `--ref` is the override.

The delivery rule is stated conditionally, because a pin is not necessarily
a branch. The field accepts anything git resolves, and neither the server
nor the daemon can tell a branch from a tag or a commit without asking the
remote — which the product deliberately does not do. A tag has nothing to
merge back into, so the agent is told to confirm rather than guess a base.

That same ambiguity shapes how the UI narrows to branches. The field now
says "Starting branch" and explains that tasks both start and deliver
there, but the promise cannot be enforced: `v1.2.3` is a legal branch name
and `main` is a legal tag. Only a full-length object id is unambiguous, and
only that is declined, pointing at `multica repo checkout --ref` where a
one-off revision belongs. The stored grammar is unchanged, so the CLI and
API still accept tags and commits — the daemon has always resolved all
three, and this is a UI-level product choice, not a data restriction.

Two fixes from the same review:

- An unpinned repository now reads "Default branch" instead of showing
  nothing. Clearing a branch used to make the line vanish, which looks
  identical to the setting never having existed — there was no way to
  confirm from the panel that a repo was deliberately on its default, or
  that the row had a branch setting at all.
- The edit dialog says the change applies to newly started work. The ref is
  read when a task is claimed, so work already underway keeps the branch it
  started on, and the copy now says so rather than leaving it to be
  discovered.

Co-authored-by: multica-agent <[email protected]>

* fix(projects): close three gaps in the starting-branch flow (MUL-7504)

All three found in review of the two commits before this one.

**A resumed task could be told to deliver somewhere new.** The starting
point in the brief is the project's CURRENT setting, read when the run was
claimed. A task that started from `release/a`, left uncommitted work and
resumed after the project moved to `release/b` kept its old checkout — the
work is still branched off `release/a` — while the brief now named
`release/b` and told it to deliver there. Following that would push the old
line's work into the new one, and would break what the edit dialog
promises: work already underway keeps the branch it started on.

The brief cannot settle this alone, because whether a checkout gets reused
is only known once `repo checkout` runs. It defers to the checkout instead,
which already reports `Kept` and names the branch it is on.

**Create Project accepted a branch it had already rejected.** The submit
gate only checked the add-a-repo field, so a branch edited on an
already-selected repo reached the payload with nothing but an inline error
to show for it — and the server accepts commit ids by design, so nothing
downstream caught it. Both the button and handleSubmit now check every
selected repo; the button alone was never enough, since TitleEditor's
onSubmit calls handleSubmit directly.

**A second pasted browse URL was stored whole.** Splitting `/tree/<branch>`
was gated on the branch field being empty, so pasting another repository's
browse URL after one was already filled in saved the entire `/tree/...`
string as the clone URL — a target that cannot be cloned. Normalising the
URL is now unconditional in all three entry points. Whether to overwrite
the BRANCH is the separate question, and the pasted pair wins: the branch
field only appears once a URL is present, so a value sitting in it came
from the previous URL rather than from something typed ahead of time.

One defect the review surfaced indirectly: GithubRefField took an optional
`id` and the per-repo instance in the create-project modal passed none,
which detached the label from its input — leaving the field unnamed for a
screen reader, and unreachable by getByLabelText, which is why the gap had
no test. The id is now generated when omitted.

Co-authored-by: multica-agent <[email protected]>

* fix(execenv): stop naming the checkout as the resumed delivery target (MUL-7504)

The resume warning added in the previous commit told the agent to deliver
kept work "to the branch it actually started from, which the checkout
names". The second clause is wrong. A kept checkout reports the branch the
worktree is ON — resolved by `git symbolic-ref`, so the task's own
`agent/<name>/<task>` branch. That is the HEAD of a pull request, never its
base, and nothing in WorktreeResult carries the ref it was cut from. An
agent following that sentence would pass its own branch as `--base` and
open a pull request whose base equals its head.

The warning now says what the reported branch actually is, and points at
the sources that do settle the question: the base of the work's existing
pull request, or the target the task states, and ask when neither does.

It also moves out of the `if pinned` branch. A project cleared back to its
default branch renders no starting point at all, yet a task resumed after
that change still holds a checkout cut from the old one — gating the
warning on a pin dropped it exactly where the mismatch is invisible. The
one sentence that only makes sense alongside a listed starting point
("do not retarget it to a starting point listed above") stays conditional,
so the cleared variant does not point at a line that is not there.

Regressions cover both resume shapes — pinned A to B, and A cleared to the
default branch — plus the specific wrong phrasing, so it cannot come back.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-09-20 17:10:08 +08:00

124 lines
4.4 KiB
TypeScript

"use client";
import { useId } from "react";
import {
looksLikeCommitSha,
validateGitRef,
type GitRefInvalidReason,
} from "@multica/core/github";
import { useT } from "../../i18n/use-t";
/**
* Free-text entry for the branch a project's tasks work on.
*
* Asks for a BRANCH specifically, though the stored field accepts any ref the
* daemon can resolve. The reason is delivery: a pinned starting point is also
* where tasks open their pull requests, and a tag or a commit has nothing to
* merge back into. Tags and commits remain reachable per task through
* `multica repo checkout --ref`, which is where a one-off revision belongs.
*
* That promise cannot be enforced here — `v1.2.3` is a legal branch name and
* `main` is a legal tag, so telling them apart means asking the remote, which
* the product deliberately does not do. The one shape that is unambiguous is a
* full-length object id, and only that is declined.
*
* Free text rather than a dropdown for the same reason: nothing in the product
* can list a repository's branches today — the server never touches the
* repository, and the daemon holding the bare caches may be offline or lack
* access to a private repo.
*
* Shared by the create-project modal and the resource panel so the same
* decision reads the same way wherever it is made.
*/
export function GithubRefField({
value,
onChange,
onSubmit,
autoFocus,
id: providedId,
}: {
value: string;
onChange: (next: string) => void;
/** Enter in the field — lets a dialog save without reaching for the mouse. */
onSubmit?: () => void;
autoFocus?: boolean;
/** Optional stable id; one is generated when omitted. */
id?: string;
}) {
const { t } = useT("projects");
// Generated rather than required, because a caller that renders one field
// PER REPO cannot supply a static id and would otherwise pass none — which
// silently detaches the label from its input and leaves the field unnamed
// for a screen reader and untargetable by getByLabelText.
const generatedId = useId();
const id = providedId ?? generatedId;
const error = refErrorMessage(value, t);
return (
<div className="space-y-1">
<label htmlFor={id} className="text-caption font-medium">
{t(($) => $.resources.ref_label)}
</label>
<input
id={id}
type="text"
value={value}
autoFocus={autoFocus}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && onSubmit) {
e.preventDefault();
onSubmit();
}
}}
spellCheck={false}
autoCapitalize="none"
autoCorrect="off"
placeholder={t(($) => $.resources.ref_placeholder)}
aria-invalid={error !== null}
aria-describedby={`${id}-hint`}
className="h-8 w-full rounded-md border bg-transparent px-2 font-mono text-caption outline-none placeholder:font-sans placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring aria-invalid:border-destructive"
/>
<p
id={`${id}-hint`}
className={`text-micro ${error ? "text-destructive" : "text-muted-foreground"}`}
>
{error ?? t(($) => $.resources.ref_hint)}
</p>
</div>
);
}
/** True when the field holds something this form will not submit. */
export function githubRefHasError(value: string): boolean {
return validateGitRef(value).ok === false || looksLikeCommitSha(value);
}
/** The message to show, or null when the value is acceptable. */
function refErrorMessage(
value: string,
t: ReturnType<typeof useT<"projects">>["t"],
): string | null {
// Checked before the grammar: a SHA is a perfectly valid ref to store, so
// validateGitRef passes it. What makes it wrong HERE is that this field
// names a branch to deliver to.
if (looksLikeCommitSha(value)) return t(($) => $.resources.ref_error_commit);
const validation = validateGitRef(value);
if (validation.ok) return null;
return grammarErrorMessage(validation.reason, t);
}
function grammarErrorMessage(
reason: GitRefInvalidReason,
t: ReturnType<typeof useT<"projects">>["t"],
): string {
switch (reason) {
case "too_long":
return t(($) => $.resources.ref_error_too_long);
case "invalid_characters":
return t(($) => $.resources.ref_error_characters);
case "invalid_format":
return t(($) => $.resources.ref_error_format);
}
}