Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ script-test:
$(call run-timed,bash scripts/bundle-sh-test.sh)
$(call run-timed,bash scripts/gitleaks-install-test.sh)
$(call run-timed,bash scripts/post-failure-report-test.sh)
$(call run-timed,bash scripts/pr-assignee-test.sh)
$(call run-timed,bash scripts/post-triage-test.sh)
$(call run-timed,bash scripts/post-prioritize-test.sh)
$(call run-timed,bash scripts/post-code-test.sh)
Expand Down
2 changes: 1 addition & 1 deletion docs/code.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ The code agent follows a three-phase pipeline: pre-script, sandbox execution, po

1. **Pre-script** validates inputs on the runner before sandbox creation. It also checks for open PRs linked to the issue.
2. **Sandbox** — the agent reads the issue, explores the codebase, writes code, runs tests and linters, and commits locally. It has no network access (enforced by OpenShell).
3. **Post-script** runs on the runner: it performs protected path checks, secret scanning, pre-commit checks, pushes the branch, and creates the PR.
3. **Post-script** runs on the runner: it performs protected path checks, secret scanning, pre-commit checks, pushes the branch, creates the PR, and best-effort assigns the PR to a human owner (latest `/fs-code` invoker, else issue assignee, else issue author).

This separation ensures the agent never has direct write access to the repository.

Expand Down
2 changes: 1 addition & 1 deletion harness/code.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# Flow: pre_script → sandbox (agent) → post_script
# pre_script : validates inputs on the runner BEFORE sandbox creation
# agent : reads the issue, implements, tests, scans, commits locally
# post_script : protected-path check, secret scan, push branch, create PR
# post_script : secret scan, pre-commit, push branch, create PR, assign human
#
# The agent NEVER pushes or creates PRs. Enforcement: the sandbox
# GH_TOKEN is read-only scoped, PUSH_TOKEN never enters the sandbox,
Expand Down
183 changes: 183 additions & 0 deletions scripts/lib/pr-assignee.lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
#!/usr/bin/env bash
# pr-assignee.lib.sh — Resolve and assign a human owner for code-agent PRs.
#
# Source from post-code.src.sh (after post-failure-report.lib.sh for gha_echo):
# source "${SCRIPT_DIR}/lib/pr-assignee.lib.sh"
#
# Precedence (first human match wins):
# 1. Most recent human /fs-code commenter on the issue (API lookup)
# 2. First human issue assignee
# 3. Human issue author
#
# Never assigns bots or GitHub Apps. Assignment is best-effort.
# No workflow TRIGGER_SOURCE plumbing required.

# shellcheck shell=bash

[[ -n "${PR_ASSIGNEE_SH_LOADED:-}" ]] && return 0
PR_ASSIGNEE_SH_LOADED=1

# Return 0 when login looks like a human GitHub user (not a bot/App).
is_human_github_user() {
local login="${1:-}"
if [[ -z "${login}" ]]; then
return 1
fi
case "${login}" in
app/*|dependabot) return 1 ;;
esac
if [[ "${login}" =~ \[bot\]$ ]]; then
return 1
fi
return 0
}

# From a REST comments JSON array, return the most recent human /fs-code invoker.
# Accepts REST shape ({user.login, body}) or GraphQL-ish ({author.login, body}).
# Matches dispatch.yml: first word of the first line is /fs-code (leading
# whitespace ignored, same as awk '{print $1}').
find_fs_code_invoker() {
local comments_json="${1:-}"
if [[ -z "${comments_json}" || "${comments_json}" == "null" ]]; then
return 1
fi

local login
login="$(echo "${comments_json}" | jq -r '
def is_bot:
(. == "dependabot") or startswith("app/") or test("\\[bot\\]$");
# Guard [0] // "" so null/missing bodies do not abort the whole filter.
def first_word:
((. // "") | split("\n")[0] // "" | gsub("\r$"; "")
| sub("^[[:space:]]+"; "") | split(" ")[0] // "");
[
.[]
| (.user.login // .author.login // "") as $login
| select(($login | length > 0) and ($login | is_bot | not))
Comment thread
ifireball marked this conversation as resolved.
| select((.body | first_word) == "/fs-code")
| $login
] | last // empty
' 2>/dev/null || true)"

if [[ -n "${login}" ]]; then
echo "${login}"
return 0
fi
return 1
}

# Resolve a human assignee from comments JSON + issue JSON.
# Prints the login on stdout, or nothing when no human matches.
# Args: comments_json issue_json
resolve_pr_assignee_from_context() {
local comments_json="${1:-}"
local issue_json="${2:-}"

local invoker
invoker="$(find_fs_code_invoker "${comments_json}" || true)"
if is_human_github_user "${invoker}"; then
echo "${invoker}"
return 0
fi

if [[ -z "${issue_json}" ]]; then
return 1
fi

local human_assignee
human_assignee="$(echo "${issue_json}" | jq -r '
[(.assignees // [])[]? | .login? // empty | select(
(. | length > 0) and
(startswith("app/") | not) and
(test("\\[bot\\]$") | not) and
(. != "dependabot")
)] | .[0] // empty
' 2>/dev/null || true)"
if [[ -n "${human_assignee}" ]]; then
echo "${human_assignee}"
return 0
fi

local author_login
author_login="$(echo "${issue_json}" | jq -r '.author.login? // empty' 2>/dev/null || true)"
if is_human_github_user "${author_login}"; then
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
echo "${author_login}"
return 0
fi

return 1
}

# Emit a runner warning through gha_echo when available (sanitizes :: / %0A / %0D).
# Never fall back to raw "::warning::" interpolation — that invites workflow-command injection.
_pr_assignee_warn() {
if declare -F gha_echo >/dev/null 2>&1; then
gha_echo warning "$*"
else
echo "warning: $*" >&2
fi
}

# Fetch issue comments (paginated REST) as a single JSON array. Best-effort.
fetch_issue_comments_json() {
local raw
if ! raw="$(gh api --paginate \
"repos/${REPO_FULL_NAME}/issues/${ISSUE_NUMBER}/comments" 2>/dev/null)"; then
echo '[]'
return 0
fi
if [[ -z "${raw}" ]]; then
echo '[]'
return 0
fi
echo "${raw}" | jq -s 'add // []' 2>/dev/null || echo '[]'
}

# Resolve using issue comments + assignees/author via GitHub API.
resolve_pr_assignee() {
local comments_json issue_json
comments_json="$(fetch_issue_comments_json)"
issue_json="$(gh issue view "${ISSUE_NUMBER}" --repo "${REPO_FULL_NAME}" \
--json assignees,author 2>/dev/null || true)"
resolve_pr_assignee_from_context "${comments_json}" "${issue_json}"
}

# Best-effort PR assignee: skip when the PR already has assignees.
# Requires REPO_FULL_NAME; uses gha_echo when available.
# Note: parameter is target_pr (not pr_number) to avoid SC2153 against PR_NUMBER
# from post-failure-report.lib.sh once both libs are bundled into post-code.sh.
maybe_assign_pr() {
local target_pr="$1"
local existing_count
if ! existing_count="$(gh pr view "${target_pr}" --repo "${REPO_FULL_NAME}" \
--json assignees --jq '.assignees | length' 2>/dev/null)"; then
_pr_assignee_warn "Could not read assignees for PR #${target_pr} — skipping assignment"
return 0
fi
if [[ "${existing_count}" != "0" ]]; then
echo "PR #${target_pr} already has assignees — skipping assignment"
return 0
fi

local assignee
assignee="$(resolve_pr_assignee || true)"
if [[ -z "${assignee}" ]]; then
echo "No human assignee candidate — leaving PR #${target_pr} unassigned"
return 0
fi
# Defense-in-depth: only pass GitHub-login-shaped values to gh.
if [[ ! "${assignee}" =~ ^[a-zA-Z0-9_-]+$ ]]; then
_pr_assignee_warn "Unexpected assignee format '${assignee}' — skipping assignment"
return 0
fi

echo "Assigning PR #${target_pr} to ${assignee}..."
local assign_err
Comment thread
ifireball marked this conversation as resolved.
assign_err="$(gh pr edit "${target_pr}" --repo "${REPO_FULL_NAME}" \
--add-assignee "${assignee}" 2>&1)" || {
_pr_assignee_warn "Failed to assign PR #${target_pr} to ${assignee} — continuing"
if [[ -n "${assign_err}" ]]; then
_pr_assignee_warn "${assign_err}"
fi
}
}
8 changes: 8 additions & 0 deletions scripts/post-code-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ else
echo "PASS: bundled-script-has-gitleaks-install"
fi

if ! grep -q 'maybe_assign_pr' "${POST_SCRIPT}"; then
echo "FAIL: bundled-script-has-pr-assignee"
echo " ${POST_SCRIPT} missing maybe_assign_pr"
FAILURES=$((FAILURES + 1))
else
echo "PASS: bundled-script-has-pr-assignee"
fi

# ---------------------------------------------------------------------------
# Test helper — reimplements the title-rewriting logic from post-code.sh
# so we can test it without a git repo or network access.
Expand Down
Loading
Loading