|
| 1 | +import type { TemplateConfig } from "../domain.js" |
| 2 | + |
| 3 | +const indentBlock = (block: string, size = 2): string => { |
| 4 | + const prefix = " ".repeat(size) |
| 5 | + |
| 6 | + return block |
| 7 | + .split("\n") |
| 8 | + .map((line) => `${prefix}${line}`) |
| 9 | + .join("\n") |
| 10 | +} |
| 11 | + |
| 12 | +const renderAgentPrompt = (): string => |
| 13 | + String.raw`AGENT_PROMPT="" |
| 14 | +ISSUE_NUM="" |
| 15 | +if [[ "$REPO_REF" =~ ^issue-([0-9]+)$ ]]; then |
| 16 | + ISSUE_NUM="${"${"}BASH_REMATCH[1]}" |
| 17 | +fi |
| 18 | +
|
| 19 | +if [[ "$AGENT_AUTO" == "1" ]]; then |
| 20 | + if [[ -n "$ISSUE_NUM" ]]; then |
| 21 | + AGENT_PROMPT="Read GitHub issue #$ISSUE_NUM for this repository (use gh issue view $ISSUE_NUM). Implement the requested changes, commit them, create a PR that closes #$ISSUE_NUM, and push it." |
| 22 | + else |
| 23 | + AGENT_PROMPT="Analyze this repository, implement any pending tasks, commit changes, create a PR, and push it." |
| 24 | + fi |
| 25 | +fi` |
| 26 | + |
| 27 | +const renderAgentSetup = (): string => |
| 28 | + [ |
| 29 | + String.raw`AGENT_DONE_PATH="/run/docker-git/agent.done" |
| 30 | +AGENT_FAIL_PATH="/run/docker-git/agent.failed" |
| 31 | +AGENT_PROMPT_FILE="/run/docker-git/agent-prompt.txt" |
| 32 | +rm -f "$AGENT_DONE_PATH" "$AGENT_FAIL_PATH" "$AGENT_PROMPT_FILE"`, |
| 33 | + String.raw`# Collect tokens for agent environment (su - dev does not always inherit profile.d) |
| 34 | +AGENT_ENV_FILE="/run/docker-git/agent-env.sh" |
| 35 | +{ |
| 36 | + [[ -f /etc/profile.d/gh-token.sh ]] && cat /etc/profile.d/gh-token.sh |
| 37 | + [[ -f /etc/profile.d/claude-config.sh ]] && cat /etc/profile.d/claude-config.sh |
| 38 | +} > "$AGENT_ENV_FILE" 2>/dev/null || true |
| 39 | +chmod 644 "$AGENT_ENV_FILE"`, |
| 40 | + renderAgentPrompt(), |
| 41 | + String.raw`AGENT_OK=0 |
| 42 | +if [[ -n "$AGENT_PROMPT" ]]; then |
| 43 | + printf "%s" "$AGENT_PROMPT" > "$AGENT_PROMPT_FILE" |
| 44 | + chmod 644 "$AGENT_PROMPT_FILE" |
| 45 | +fi` |
| 46 | + ].join("\n\n") |
| 47 | + |
| 48 | +const renderAgentPromptCommand = (mode: "claude" | "codex"): string => |
| 49 | + mode === "claude" |
| 50 | + ? String.raw`claude --dangerously-skip-permissions -p \"\$(cat $AGENT_PROMPT_FILE)\"` |
| 51 | + : String.raw`codex --approval-mode full-auto \"\$(cat $AGENT_PROMPT_FILE)\"` |
| 52 | + |
| 53 | +const renderAgentModeBlock = ( |
| 54 | + config: TemplateConfig, |
| 55 | + mode: "claude" | "codex" |
| 56 | +): string => { |
| 57 | + const startMessage = `[agent] starting ${mode}...` |
| 58 | + const interactiveMessage = `[agent] ${mode} started in interactive mode (use SSH to connect)` |
| 59 | + |
| 60 | + return String.raw`"${mode}") |
| 61 | + echo "${startMessage}" |
| 62 | + if [[ -n "$AGENT_PROMPT" ]]; then |
| 63 | + if su - ${config.sshUser} \ |
| 64 | + -c ". /run/docker-git/agent-env.sh 2>/dev/null; cd '$TARGET_DIR' && ${renderAgentPromptCommand(mode)}"; then |
| 65 | + AGENT_OK=1 |
| 66 | + fi |
| 67 | + else |
| 68 | + echo "${interactiveMessage}" |
| 69 | + AGENT_OK=1 |
| 70 | + fi |
| 71 | + ;;` |
| 72 | +} |
| 73 | + |
| 74 | +const renderAgentModeCase = (config: TemplateConfig): string => |
| 75 | + [ |
| 76 | + String.raw`case "$AGENT_MODE" in`, |
| 77 | + indentBlock(renderAgentModeBlock(config, "claude")), |
| 78 | + indentBlock(renderAgentModeBlock(config, "codex")), |
| 79 | + indentBlock( |
| 80 | + String.raw`*) |
| 81 | + echo "[agent] unknown agent mode: $AGENT_MODE" |
| 82 | + ;;` |
| 83 | + ), |
| 84 | + "esac" |
| 85 | + ].join("\n") |
| 86 | + |
| 87 | +const renderAgentIssueComment = (config: TemplateConfig): string => |
| 88 | + String.raw`echo "[agent] posting review comment to issue #$ISSUE_NUM..." |
| 89 | +
|
| 90 | +PR_BODY="" |
| 91 | +PR_BODY=$(su - ${config.sshUser} -c ". /run/docker-git/agent-env.sh 2>/dev/null; cd '$TARGET_DIR' && gh pr list --head '$REPO_REF' --json body --jq '.[0].body'" 2>/dev/null) || true |
| 92 | +
|
| 93 | +if [[ -z "$PR_BODY" ]]; then |
| 94 | + PR_BODY=$(su - ${config.sshUser} -c ". /run/docker-git/agent-env.sh 2>/dev/null; cd '$TARGET_DIR' && git log --format='%B' -1" 2>/dev/null) || true |
| 95 | +fi |
| 96 | +
|
| 97 | +if [[ -n "$PR_BODY" ]]; then |
| 98 | + COMMENT_FILE="/run/docker-git/agent-comment.txt" |
| 99 | + printf "%s" "$PR_BODY" > "$COMMENT_FILE" |
| 100 | + chmod 644 "$COMMENT_FILE" |
| 101 | + su - ${config.sshUser} -c ". /run/docker-git/agent-env.sh 2>/dev/null; cd '$TARGET_DIR' && gh issue comment '$ISSUE_NUM' --body-file '$COMMENT_FILE'" || echo "[agent] failed to comment on issue #$ISSUE_NUM" |
| 102 | +else |
| 103 | + echo "[agent] no PR body or commit message found, skipping comment" |
| 104 | +fi` |
| 105 | + |
| 106 | +const renderProjectMoveScript = (): string => |
| 107 | + String.raw`#!/bin/bash |
| 108 | +. /run/docker-git/agent-env.sh 2>/dev/null || true |
| 109 | +cd "$1" || exit 1 |
| 110 | +ISSUE_NUM="$2" |
| 111 | +
|
| 112 | +ISSUE_NODE_ID=$(gh issue view "$ISSUE_NUM" --json id --jq '.id' 2>/dev/null) || true |
| 113 | +if [[ -z "$ISSUE_NODE_ID" ]]; then |
| 114 | + echo "[agent] could not get issue node ID, skipping move" |
| 115 | + exit 0 |
| 116 | +fi |
| 117 | +
|
| 118 | +GQL_QUERY='query($nodeId: ID!) { node(id: $nodeId) { ... on Issue { projectItems(first: 1) { nodes { id project { id field(name: "Status") { ... on ProjectV2SingleSelectField { id options { id name } } } } } } } } }' |
| 119 | +ALL_IDS=$(gh api graphql -F nodeId="$ISSUE_NODE_ID" -f query="$GQL_QUERY" \ |
| 120 | + --jq '(.data.node.projectItems.nodes // [])[0] // empty | [.id, .project.id, .project.field.id, ([.project.field.options[] | select(.name | test("review"; "i"))][0].id)] | @tsv' 2>/dev/null) || true |
| 121 | +
|
| 122 | +if [[ -z "$ALL_IDS" ]]; then |
| 123 | + echo "[agent] issue #$ISSUE_NUM is not in a project board, skipping move" |
| 124 | + exit 0 |
| 125 | +fi |
| 126 | +
|
| 127 | +ITEM_ID=$(printf "%s" "$ALL_IDS" | cut -f1) |
| 128 | +PROJECT_ID=$(printf "%s" "$ALL_IDS" | cut -f2) |
| 129 | +STATUS_FIELD_ID=$(printf "%s" "$ALL_IDS" | cut -f3) |
| 130 | +REVIEW_OPTION_ID=$(printf "%s" "$ALL_IDS" | cut -f4) |
| 131 | +if [[ -z "$STATUS_FIELD_ID" || -z "$REVIEW_OPTION_ID" || "$STATUS_FIELD_ID" == "null" || "$REVIEW_OPTION_ID" == "null" ]]; then |
| 132 | + echo "[agent] review status not found in project board, skipping move" |
| 133 | + exit 0 |
| 134 | +fi |
| 135 | +
|
| 136 | +MUTATION='mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { updateProjectV2ItemFieldValue(input: { projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: { singleSelectOptionId: $optionId } }) { projectV2Item { id } } }' |
| 137 | +MOVE_RESULT=$(gh api graphql \ |
| 138 | + -F projectId="$PROJECT_ID" \ |
| 139 | + -F itemId="$ITEM_ID" \ |
| 140 | + -F fieldId="$STATUS_FIELD_ID" \ |
| 141 | + -F optionId="$REVIEW_OPTION_ID" \ |
| 142 | + -f query="$MUTATION" 2>&1) || true |
| 143 | +
|
| 144 | +if [[ "$MOVE_RESULT" == *"projectV2Item"* ]]; then |
| 145 | + echo "[agent] issue #$ISSUE_NUM moved to review" |
| 146 | +else |
| 147 | + echo "[agent] failed to move issue #$ISSUE_NUM in project board" |
| 148 | +fi` |
| 149 | + |
| 150 | +const renderAgentIssueMove = (config: TemplateConfig): string => |
| 151 | + [ |
| 152 | + String.raw`echo "[agent] moving issue #$ISSUE_NUM to review..." |
| 153 | +MOVE_SCRIPT="/run/docker-git/project-move.sh"`, |
| 154 | + String.raw`cat > "$MOVE_SCRIPT" << 'EOFMOVE' |
| 155 | +${renderProjectMoveScript()} |
| 156 | +EOFMOVE`, |
| 157 | + String.raw`chmod +x "$MOVE_SCRIPT" |
| 158 | +su - ${config.sshUser} -c "$MOVE_SCRIPT '$TARGET_DIR' '$ISSUE_NUM'" || true` |
| 159 | + ].join("\n") |
| 160 | + |
| 161 | +const renderAgentIssueReview = (config: TemplateConfig): string => |
| 162 | + [ |
| 163 | + String.raw`if [[ "$AGENT_OK" -eq 1 && "$AGENT_AUTO" == "1" && -n "$ISSUE_NUM" ]]; then`, |
| 164 | + indentBlock(renderAgentIssueComment(config)), |
| 165 | + "", |
| 166 | + indentBlock(renderAgentIssueMove(config)), |
| 167 | + "fi" |
| 168 | + ].join("\n") |
| 169 | + |
| 170 | +const renderAgentFinalize = (): string => |
| 171 | + String.raw`if [[ "$AGENT_OK" -eq 1 ]]; then |
| 172 | + echo "[agent] done" |
| 173 | + touch "$AGENT_DONE_PATH" |
| 174 | +else |
| 175 | + echo "[agent] failed" |
| 176 | + touch "$AGENT_FAIL_PATH" |
| 177 | +fi` |
| 178 | + |
| 179 | +export const renderAgentLaunch = (config: TemplateConfig): string => |
| 180 | + [ |
| 181 | + String.raw`# 3) Auto-launch agent if AGENT_MODE is set |
| 182 | +if [[ "$CLONE_OK" -eq 1 && -n "$AGENT_MODE" ]]; then`, |
| 183 | + indentBlock(renderAgentSetup()), |
| 184 | + "", |
| 185 | + indentBlock(renderAgentModeCase(config)), |
| 186 | + "", |
| 187 | + indentBlock(renderAgentIssueReview(config)), |
| 188 | + "", |
| 189 | + indentBlock(renderAgentFinalize()), |
| 190 | + "fi" |
| 191 | + ].join("\n") |
0 commit comments