Skip to content

Commit 6403bd4

Browse files
committed
feat(epic): link branch to issue via createLinkedBranch instead of cross-ref comment
GitHub's GraphQL exposes a createLinkedBranch mutation that creates a branch officially linked to an issue (the same link that powers the 'Development' sidebar in the issue UI). When a PR is opened from such a branch, it is auto-linked to the issue — natively, in the proper slot, no Closes-#N body keyword required. Replace the ad-hoc 'Linked PR: #N' comment workaround with this native mechanism: - New helper create_linked_branch.sh: idempotent (reuses an existing ticket/<N>-* branch if any), generates a slug from the issue title, resolves the base SHA via git ls-remote, calls createLinkedBranch. Returns the branch name on stdout. - epic_loop.sh: calls the helper after ensure_worktree, passes the branch name to spawn_agent and into the prompt. - code-dev/AGENT.md: step 4 now does 'git fetch origin <branch>' + 'git checkout <branch>' (no more 'checkout -b') — the agent works on the pre-created linked branch. - process_tick_result.sh: drops the link_pr_to_ticket.sh call from the validated branch (no longer needed). - link_pr_to_ticket.sh: deleted (obsolete). Net effect: cleaner UX (PR appears in the issue's Development sidebar with no extra noise in the comments stream).
1 parent c656794 commit 6403bd4

5 files changed

Lines changed: 126 additions & 65 deletions

File tree

.claude/agents/code-dev/AGENT.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ You execute one pre-specified ticket end-to-end : edit code, write/update tests,
1616
- **Base branch** (assigned by `/epic` ou `/code`) — toujours au format **remote-tracking ref** (`origin/<branch>`), déjà fetchée par l'orchestrateur :
1717
- `origin/alpha` (ou `origin/chore/ai-pipeline` pendant la phase de transition) si aucune dépendance ou toutes mergées
1818
- `origin/ticket/<parent-slug>` si le ticket dépend d'un autre encore en `In review` (stacked PR)
19+
- **Working branch** (assigned by `/epic`) — déjà créée sur GitHub par l'orchestrateur via `createLinkedBranch` GraphQL et **officiellement linkée à l'issue** (sidebar Development). La PR ouverte depuis cette branche y apparaîtra automatiquement — pas besoin de cross-reference comment.
1920

2021
## Workflow
2122

@@ -27,10 +28,11 @@ You execute one pre-specified ticket end-to-end : edit code, write/update tests,
2728

2829
3. **Status ticket****In progress** via `bash scripts/orchestration/set_ticket_status.sh <N> "In progress"`.
2930

30-
4. **Branche** dans le worktree, à partir de la **base branch** reçue en input (déjà au format `origin/<x>` et déjà fetchée par l'orchestrateur — **ne pas re-fetcher**) :
31+
4. **Checkout la branche linkée pré-créée** (la `Working branch` reçue en input). **Ne pas créer une nouvelle branche** — la branche existe déjà sur GitHub, est linkée à l'issue, et c'est sur elle que tu dois pousser :
3132
- `cd <worktree>` (le worktree est en mode `--detach` sur la base)
32-
- `git checkout -b ticket/<N>-<slug> <base-branch>` (ex: `git checkout -b ticket/123-foo origin/alpha`)
33-
- Si `<base-branch>` n'est pas `origin/alpha` (ou `origin/chore/ai-pipeline` en phase de transition), on est en mode **stacked PR** — la PR sera ouverte avec `--base <base-branch-sans-prefix-origin>` (ex: `--base ticket/<parent-slug>`), GitHub retargettera vers `alpha` quand la PR parent sera mergée
33+
- `git fetch origin <working-branch>`
34+
- `git checkout <working-branch>` (PAS `checkout -b`)
35+
- Si la **base branch** n'est pas `origin/alpha` (ou `origin/chore/ai-pipeline` en phase de transition), on est en mode **stacked PR** — la PR sera ouverte avec `--base <base-branch-sans-prefix-origin>` (ex: `--base ticket/<parent-slug>`), GitHub retargettera vers `alpha` quand la PR parent sera mergée
3436

3537
4.5. **Sanity check stack docker** — vérifier que `packages/app/.env.local` existe et contient `COMPOSE_PROJECT_NAME=egapro-wt-*`. Si absent → `scripts/setup-worktree.sh <index> [<extras>]` (où `<extras>` vient du parsing de la section `## Requires services` du ticket). Si `/epic` ou `/code` a déjà lancé le setup, l'étape est un no-op.
3638

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
#!/usr/bin/env bash
2+
# create_linked_branch.sh <ticket_number> <base_branch>
3+
#
4+
# Creates a branch officially "linked" to the GitHub issue (i.e. it shows up
5+
# in the issue's sidebar "Development" section). The PR opened later from
6+
# this branch is then auto-linked to the issue too — no need for a manual
7+
# cross-reference comment, no dependency on the "Closes #N" body keyword
8+
# (which only fires when the PR targets the default branch).
9+
#
10+
# Idempotent: if a branch matching `ticket/<N>-*` already exists on origin,
11+
# it is reused and its name is returned. The linkedBranch GraphQL state is
12+
# *not* re-asserted in that case (re-running createLinkedBranch on an existing
13+
# linked ref would fail, and a stale branch from a prior cancelled run is
14+
# already linked).
15+
#
16+
# Stdout: the branch name (e.g. ticket/3313-annotate-indicators-af).
17+
# Stderr: progress + any error.
18+
#
19+
# Usage:
20+
# create_linked_branch.sh 3313 origin/chore/ai-pipeline
21+
# create_linked_branch.sh 3313 origin/ticket/3312-post-processing-wiki
22+
23+
set -euo pipefail
24+
25+
if [ $# -lt 2 ]; then
26+
echo "Usage: $0 <ticket_number> <base_branch>" >&2
27+
exit 2
28+
fi
29+
30+
TICKET="$1"
31+
BASE="$2"
32+
33+
# 1. Reuse existing branch if any (idempotent across re-runs)
34+
EXISTING=$(git ls-remote --heads origin "ticket/${TICKET}-*" 2>/dev/null \
35+
| awk '{print $2}' | sed 's|refs/heads/||' | head -1)
36+
if [ -n "$EXISTING" ]; then
37+
echo "[create_linked_branch] reusing existing branch: $EXISTING" >&2
38+
echo "$EXISTING"
39+
exit 0
40+
fi
41+
42+
# 2. Compute the slug from the issue title
43+
TITLE=$(gh issue view "$TICKET" --json title --jq '.title')
44+
# Strip leading prefix like "T0 — " / "T1 - " / "[bug] " (best-effort)
45+
TITLE_TRIMMED=$(echo "$TITLE" | sed -E 's/^(T[0-9]+ *[—–-] *|\[[^]]+\] *)//')
46+
# Slugify: lowercase, ASCII-fold removes accents, non-alphanumeric → '-',
47+
# collapse runs of '-', trim leading/trailing '-', cap at 40 chars.
48+
SLUG=$(echo "$TITLE_TRIMMED" \
49+
| iconv -f UTF-8 -t ASCII//TRANSLIT 2>/dev/null \
50+
| tr '[:upper:]' '[:lower:]' \
51+
| sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//' \
52+
| head -c 40 \
53+
| sed -E 's/-+$//')
54+
55+
if [ -z "$SLUG" ]; then
56+
SLUG="ticket"
57+
fi
58+
59+
NAME="ticket/${TICKET}-${SLUG}"
60+
61+
# 3. Resolve the base branch SHA. Accept either a remote-tracking ref
62+
# (origin/<x>) or a bare branch name (<x>).
63+
BASE_REF="${BASE#origin/}"
64+
OID=$(git ls-remote --heads origin "$BASE_REF" 2>/dev/null | awk '{print $1}' | head -1)
65+
if [ -z "$OID" ]; then
66+
echo "[create_linked_branch] ERROR: cannot resolve SHA for base $BASE" >&2
67+
exit 1
68+
fi
69+
70+
# 4. Resolve the issue's GraphQL node ID
71+
ISSUE_ID=$(gh issue view "$TICKET" --json id --jq '.id')
72+
if [ -z "$ISSUE_ID" ]; then
73+
echo "[create_linked_branch] ERROR: cannot resolve issue node id for #$TICKET" >&2
74+
exit 1
75+
fi
76+
77+
# 5. Create the linked branch
78+
echo "[create_linked_branch] creating $NAME from $BASE ($OID) linked to issue #$TICKET" >&2
79+
RESULT=$(gh api graphql -f query='
80+
mutation($issueId: ID!, $oid: GitObjectID!, $name: String!) {
81+
createLinkedBranch(input: { issueId: $issueId, oid: $oid, name: $name }) {
82+
linkedBranch { ref { name } }
83+
}
84+
}' -f issueId="$ISSUE_ID" -f oid="$OID" -f name="$NAME" 2>&1)
85+
86+
CREATED_NAME=$(echo "$RESULT" | jq -r '.data.createLinkedBranch.linkedBranch.ref.name // ""' 2>/dev/null || echo "")
87+
if [ -z "$CREATED_NAME" ]; then
88+
echo "[create_linked_branch] ERROR: createLinkedBranch failed:" >&2
89+
echo "$RESULT" >&2
90+
exit 1
91+
fi
92+
93+
echo "[create_linked_branch] linked branch created: $CREATED_NAME" >&2
94+
echo "$CREATED_NAME"

scripts/orchestration/epic_loop.sh

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ spawn_agent() {
124124
local BASE="$6"
125125
local WT_PATH="$7"
126126
local AGENT_LOG="$8"
127+
local BRANCH="$9"
127128

128129
local BUDGET="$BUDGET_SONNET"
129130
[ "$MODEL" = "opus" ] && BUDGET="$BUDGET_OPUS"
@@ -134,18 +135,25 @@ spawn_agent() {
134135
Worktree: ${WT_PATH}
135136
Worktree index: ${INDEX} (dev server port = ${PORT})
136137
Base branch: ${BASE} (remote-tracking ref, déjà fetchée par l'orchestrateur)
138+
Working branch: ${BRANCH} (déjà créée et linkée à l'issue via createLinkedBranch — pousse tes commits dessus)
137139
138140
Suivre STRICTEMENT le workflow de .claude/agents/code-dev/AGENT.md.
139141
140142
L'orchestrateur a déjà :
141143
- créé le worktree en mode --detach sur ${BASE}
142144
- lancé setup-worktree.sh ${INDEX} (pnpm install + stack docker + .env.local + migrations OK)
143145
- passé le ticket #${TICKET} en \"In progress\" sur le board
146+
- **créé la branche ${BRANCH}** sur GitHub, officiellement linkée à l'issue #${TICKET} (la PR que tu ouvriras y apparaîtra automatiquement dans la sidebar Development — pas besoin de comment cross-ref)
144147
145-
Toi : depuis le worktree, créer la branche ticket/${TICKET}-<slug> à partir de
146-
${BASE} (remote-tracking ref, donc \`git checkout -b ticket/${TICKET}-<slug> ${BASE}\`,
147-
**pas besoin de re-fetch**), implémenter, créer la PR draft, faire les 4 + 2
148-
validators internes, itérer sur les RETRY, retourner le verdict final JSON.
148+
Toi : depuis le worktree, fetch et checkout la branche existante :
149+
\`\`\`bash
150+
cd ${WT_PATH}
151+
git fetch origin ${BRANCH}
152+
git checkout ${BRANCH}
153+
\`\`\`
154+
Puis implémenter, push tes commits sur ${BRANCH}, créer la PR draft (\`gh pr create --base ${BASE#origin/} --head ${BRANCH}\`), faire les 4 + 2 validators internes, itérer sur les RETRY, retourner le verdict final JSON.
155+
156+
**Ne crée PAS une autre branche** (pas de \`checkout -b\`). La branche ${BRANCH} est déjà créée et linkée — utilise-la telle quelle.
149157
150158
REGLES STRICTES (appliquer sans exception) :
151159
- **N'invoque AUCUN skill built-in** (fewer-permission-prompts, update-config,
@@ -258,12 +266,21 @@ while [ $TICK -lt $MAX_TICKS ]; do
258266
continue
259267
fi
260268

269+
# Create (or reuse) a branch linked to the issue. The PR opened from
270+
# this branch will then be auto-attached in the issue's "Development"
271+
# sidebar — no manual cross-ref comment needed.
272+
BRANCH=$(bash "$SCRIPT_DIR/create_linked_branch.sh" "$TICKET" "$BASE" 2>/dev/null)
273+
if [ -z "$BRANCH" ]; then
274+
bash "$SCRIPT_DIR/log_event.sh" "$AID" LINKED_BRANCH_FAIL "ticket=$TICKET base=$BASE"
275+
continue
276+
fi
277+
261278
AGENT_LOG="$TICK_DIR/tick_${TICK}_agent_${TICKET}.json"
262279
AGENT_FILES[$TICKET]="$AGENT_LOG"
263280

264-
bash "$SCRIPT_DIR/log_event.sh" "$AID" AGENT_SPAWN "ticket=$TICKET agent=$AGENT model=$MODEL index=$INDEX budget=$([ "$MODEL" = "opus" ] && echo "$BUDGET_OPUS" || echo "$BUDGET_SONNET")"
281+
bash "$SCRIPT_DIR/log_event.sh" "$AID" AGENT_SPAWN "ticket=$TICKET agent=$AGENT model=$MODEL index=$INDEX branch=$BRANCH budget=$([ "$MODEL" = "opus" ] && echo "$BUDGET_OPUS" || echo "$BUDGET_SONNET")"
265282

266-
spawn_agent "$TICKET" "$AGENT" "$MODEL" "$INDEX" "$EPIC" "$BASE" "$WT_PATH" "$AGENT_LOG" &
283+
spawn_agent "$TICKET" "$AGENT" "$MODEL" "$INDEX" "$EPIC" "$BASE" "$WT_PATH" "$AGENT_LOG" "$BRANCH" &
267284
PIDS+=($!)
268285
done
269286

scripts/orchestration/link_pr_to_ticket.sh

Lines changed: 0 additions & 49 deletions
This file was deleted.

scripts/orchestration/process_tick_result.sh

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -105,15 +105,12 @@ jq -c '.results[]' "$RESULT_FILE" | while read -r result; do
105105

106106
case "$STATUS" in
107107
validated)
108-
# code-dev has set 'In review' and pr ready. Just reset attempt counter.
108+
# code-dev has set 'In review' and pr ready. The PR is already
109+
# linked to the issue via the linked-branch flow (createLinkedBranch
110+
# called by epic_loop.sh before spawning the agent), so no manual
111+
# cross-ref is needed. Just reset the attempt counter.
109112
clear_attempt_labels "$TICKET"
110113
invalidate_caches "$TICKET"
111-
# Cross-reference the PR on the ticket (GitHub's native auto-link
112-
# via Closes #N doesn't fire when the PR base isn't the default
113-
# branch — typical of our stacked PR / transition-base runs).
114-
if [ -n "$PR" ] && [ "$PR" != "null" ]; then
115-
bash "$SCRIPT_DIR/link_pr_to_ticket.sh" "$TICKET" "$PR" || true
116-
fi
117114
bash "$SCRIPT_DIR/log_event.sh" "$AID" VALIDATED "ticket=$TICKET pr=$PR"
118115
echo " ✓ ticket #$TICKET validated (In review, PR ready)"
119116
N_VALIDATED=$((N_VALIDATED + 1))

0 commit comments

Comments
 (0)