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
84 changes: 73 additions & 11 deletions devloop
Original file line number Diff line number Diff line change
Expand Up @@ -3112,7 +3112,7 @@ run_devloop() {

local naming_error
naming_error=""
if ! resolve_work_item "$coder" "$SOURCE_REPO" "$spec" "$spec_text"; then
if ! resolve_work_item "$coder" "$SOURCE_REPO" "$spec" "$spec_text" "$create_pr"; then
naming_error="$WORK_ITEM_ERROR"
event_gate "branch name" 0 "$naming_error"
if [ -n "${WORK_ITEM_LOG:-}" ]; then
Expand Down Expand Up @@ -3243,7 +3243,7 @@ run_devloop() {
if [ "$create_pr" = true ] && [ -n "$PASS_COMMIT" ]; then
local pr_id="pull-request-$pass"
event_step "$pr_id" "pushing branch and opening draft PR"
if create_pull_request "$repo" "$FINAL_BRANCH" "$base" "$spec" "$criteria_file" "$FINAL_COMMIT" "$SOURCE_REPO"; then
if create_pull_request "$repo" "$FINAL_BRANCH" "$base" "$spec" "$criteria_file" "$FINAL_COMMIT" "$SOURCE_REPO" "$(pull_request_title "$WORK_TYPE" "$WORK_BREAKING" "$WORK_SCOPE" "$WORK_DESCRIPTION")"; then
event_done "$pr_id" true "draft PR ready: $PULL_REQUEST"
if ! sync_spec_pr "$spec" "$PULL_REQUEST"; then
event_log "$pr_id" "spec backlink skipped (could not update $spec)"
Expand Down Expand Up @@ -3864,6 +3864,8 @@ parse_bool() {
WORK_TYPE=""
WORK_SLUG=""
WORK_BREAKING=""
WORK_SCOPE=""
WORK_DESCRIPTION=""
WORK_ITEM_ERROR=""
WORK_ITEM_LOG=""

Expand All @@ -3872,16 +3874,21 @@ resolve_work_item() {
local repo="$2"
local spec="$3"
local spec_text="$4"
local require_pr_title="${5:-false}"
WORK_TYPE=""
WORK_SLUG=""
WORK_BREAKING=""
WORK_SCOPE=""
WORK_DESCRIPTION=""
WORK_ITEM_ERROR=""
WORK_ITEM_LOG=""

local fm_type fm_slug fm_breaking_raw fm_breaking
local fm_type fm_slug fm_breaking_raw fm_breaking fm_scope fm_description
fm_type="$(frontmatter_value "type" "$spec_text")"
fm_slug="$(frontmatter_value "slug" "$spec_text")"
fm_breaking_raw="$(frontmatter_value "breaking" "$spec_text")"
fm_scope="$(frontmatter_value "scope" "$spec_text")"
fm_description="$(frontmatter_value "description" "$spec_text")"
fm_breaking=""

if [ -n "$fm_type" ]; then
Expand All @@ -3899,6 +3906,8 @@ resolve_work_item() {
fi

if [ -n "$fm_slug" ]; then fm_slug="$(slugify "$fm_slug")"; fi
if [ -n "$fm_scope" ]; then fm_scope="$(slugify "$fm_scope")"; fi
if [ -n "$fm_description" ]; then fm_description="$(normalize_pr_description "$fm_description")"; fi

if [ -n "$fm_breaking_raw" ]; then
local parsed_breaking
Expand All @@ -3913,11 +3922,17 @@ resolve_work_item() {
fm_breaking="$parsed_breaking"
fi

if [ -n "$fm_type" ] && [ -n "$fm_slug" ] && [ -n "$fm_breaking" ]; then
if [ -n "$fm_type" ] && [ -n "$fm_slug" ] && [ -n "$fm_breaking" ] &&
{ [ "$require_pr_title" != true ] || { [ -n "$fm_scope" ] && [ -n "$fm_description" ]; }; }; then
validate_work_item "$fm_type" "$fm_slug" "$fm_breaking" || return 1
if [ "$require_pr_title" = true ]; then
validate_pr_title_parts "$fm_scope" "$fm_description" || return 1
fi
WORK_TYPE="$fm_type"
WORK_SLUG="$fm_slug"
WORK_BREAKING="$fm_breaking"
WORK_SCOPE="$fm_scope"
WORK_DESCRIPTION="$fm_description"
WORK_ITEM_LOG=""
return 0
fi
Expand All @@ -3938,13 +3953,20 @@ resolve_work_item() {
local final_type="$WORK_TYPE"
local final_slug="$WORK_SLUG"
local final_breaking="$WORK_BREAKING"
local final_scope="$WORK_SCOPE"
local final_description="$WORK_DESCRIPTION"
if [ -n "$fm_type" ]; then final_type="$fm_type"; fi
if [ -n "$fm_slug" ]; then final_slug="$fm_slug"; fi
if [ -n "$fm_breaking" ]; then final_breaking="$fm_breaking"; fi
if [ -n "$fm_scope" ]; then final_scope="$fm_scope"; fi
if [ -n "$fm_description" ]; then final_description="$fm_description"; fi
validate_work_item "$final_type" "$final_slug" "$final_breaking" || return 1
validate_pr_title_parts "$final_scope" "$final_description" || return 1
WORK_TYPE="$final_type"
WORK_SLUG="$final_slug"
WORK_BREAKING="$final_breaking"
WORK_SCOPE="$final_scope"
WORK_DESCRIPTION="$final_description"
}

validate_work_item() {
Expand All @@ -3969,9 +3991,23 @@ validate_work_item() {
esac
}

normalize_pr_description() {
printf '%s\n' "$1" |
tr '[:upper:]' '[:lower:]' |
sed -E 's/[[:space:]]+/ /g; s/^[[:space:]]+//; s/[[:space:].]+$//'
}

validate_pr_title_parts() {
local scope="$1"
local description="$2"
if [ -z "$scope" ]; then WORK_ITEM_ERROR="naming output scope is required"; return 1; fi
if [ "$(slugify "$scope")" != "$scope" ]; then WORK_ITEM_ERROR="naming output scope must be lowercase kebab-case"; return 1; fi
if [ -z "$(normalize_pr_description "$description")" ]; then WORK_ITEM_ERROR="naming output description is required"; return 1; fi
}

parse_work_item() {
local output="$1"
local candidates candidate type slug breaking index
local candidates candidate type slug breaking scope description index
local items=()
candidates="$(mktemp "${TMPDIR:-/tmp}/devloop-json.XXXXXX")"
printf '%s\n' "$output" | grep -Eo '\{[^{}]*\}' > "$candidates" || true
Expand All @@ -3983,11 +4019,17 @@ parse_work_item() {
type="$(printf '%s\n' "$candidate" | sed -nE 's/.*"type"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p')"
slug="$(printf '%s\n' "$candidate" | sed -nE 's/.*"slug"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p')"
breaking="$(printf '%s\n' "$candidate" | sed -nE 's/.*"breaking"[[:space:]]*:[[:space:]]*(true|false).*/\1/p')"
if [ -n "$type" ] && [ -n "$slug" ] && [ -n "$breaking" ]; then
if validate_work_item "$type" "$slug" "$breaking"; then
scope="$(printf '%s\n' "$candidate" | sed -nE 's/.*"scope"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p')"
description="$(printf '%s\n' "$candidate" | sed -nE 's/.*"description"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p')"
if [ -n "$type" ] && [ -n "$slug" ] && [ -n "$breaking" ] && [ -n "$scope" ] && [ -n "$description" ]; then
scope="$(slugify "$scope")"
description="$(normalize_pr_description "$description")"
if validate_work_item "$type" "$slug" "$breaking" && validate_pr_title_parts "$scope" "$description"; then
WORK_TYPE="$type"
WORK_SLUG="$(slugify "$slug")"
WORK_BREAKING="$breaking"
WORK_SCOPE="$scope"
WORK_DESCRIPTION="$description"
rm -f "$candidates"
return 0
fi
Expand All @@ -4007,10 +4049,15 @@ Work item naming task.
Read the spec and, when useful, inspect the repository to choose the semantic work item identity.

Return exactly one JSON object and no markdown:
{"type":"feat","slug":"short-kebab-case-name","breaking":false}
{"type":"feat","scope":"domain","description":"concise work summary","slug":"short-kebab-case-name","breaking":false}

Rules:
- type must be one of: feat, fix, chore.
- scope must be one short lowercase domain or component name, such as simulator, recording, cli, or pytest.
- description must be concise lowercase prose that describes the work without a type or scope prefix.
- description may repeat the scope when that reads naturally, as in "simulator result validation".
- Prefer imperative descriptions when they read naturally, such as "remove end of recording delay".
- A valid title built from the fields should read like "feat(simulator): simulator result validation".
- Use feat for new capability or materially expanded behavior.
- Use fix for correcting broken, incorrect, or regressed behavior.
- Use chore for maintenance, docs, tests, dependency work, refactors, and internal cleanup.
Expand Down Expand Up @@ -4642,6 +4689,18 @@ pass_commit_message() {
fi
}

pull_request_title() {
local type="$1"
local breaking="$2"
local scope="$3"
local description="$4"
if [ "$breaking" = true ]; then
printf '%s(%s)!: %s\n' "$type" "$scope" "$description"
else
printf '%s(%s): %s\n' "$type" "$scope" "$description"
fi
}

paste_join() {
local file="$1"
local separator="$2"
Expand Down Expand Up @@ -4728,7 +4787,8 @@ create_pull_request() {
local criteria_file="${5:-}"
local commit="${6:-}"
local source_repo="${7:-}"
ensure_pull_request "$repo" "$branch" "$base" "$spec" "$criteria_file" "$commit" "$source_repo"
local title="${8:-}"
ensure_pull_request "$repo" "$branch" "$base" "$spec" "$criteria_file" "$commit" "$source_repo" "$title"
}

push_pull_request_branch() {
Expand Down Expand Up @@ -4856,6 +4916,7 @@ create_draft_pull_request() {
local criteria_file="${5:-}"
local commit="${6:-}"
local source_repo="${7:-}"
local title="${8:-}"
local body_file out
if ! body_file="$(mktemp "${TMPDIR:-/tmp}/devloop-pr-create.XXXXXX")"; then
PULL_REQUEST_ERROR="PR body file failed: mktemp failed"
Expand All @@ -4866,7 +4927,7 @@ create_draft_pull_request() {
rm -f "$body_file"
return 1
fi
if ! run_compact_command "$repo" "open draft pull request" gh pr create --draft --fill --base "$base" --head "$branch" --body-file "$body_file"; then
if ! run_compact_command "$repo" "open draft pull request" gh pr create --draft --base "$base" --head "$branch" --title "$title" --body-file "$body_file"; then
out="$RUN_OUTPUT"
PULL_REQUEST_ERROR="PR creation failed: $(gh_error_detail "$out")"
rm -f "$body_file"
Expand All @@ -4886,11 +4947,12 @@ ensure_pull_request() {
local criteria_file="${5:-}"
local commit="${6:-}"
local source_repo="${7:-}"
local title="${8:-}"
PULL_REQUEST_ERROR=""
if ! push_pull_request_branch "$repo" "$branch"; then return 1; fi
if ! lookup_pull_request "$repo" "$branch"; then return 1; fi
if [ -n "$PULL_REQUEST" ]; then return 0; fi
create_draft_pull_request "$repo" "$branch" "$base" "$spec" "$criteria_file" "$commit" "$source_repo"
create_draft_pull_request "$repo" "$branch" "$base" "$spec" "$criteria_file" "$commit" "$source_repo" "$title"
}

round_review_comment_body() {
Expand Down
23 changes: 19 additions & 4 deletions scripts/devloop_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -996,9 +996,11 @@ if devloop_version_gt 1.2.3 1.2.3; then fail "devloop version comparison accepte
if devloop_version_gt 1.2.3-alpha.1 1.2.3; then fail "devloop version comparison accepted prerelease over release"; fi
if devloop_prompt_tty_ready; then fail "update prompt tty check accepted non-tty test shell"; fi

frontmatter_text=$'---\ntype: fix!\nslug: "Chat Retry"\nbreaking: true\nempty: null\n---\n# Title'
frontmatter_text=$'---\ntype: fix!\nslug: "Chat Retry"\nbreaking: true\nscope: "Chat"\ndescription: "Retry Failed Chats."\nempty: null\n---\n# Title'
equals "$(frontmatter_value type "$frontmatter_text")" "fix!" "frontmatter type"
equals "$(frontmatter_value slug "$frontmatter_text")" "Chat Retry" "frontmatter slug"
equals "$(frontmatter_value scope "$frontmatter_text")" "Chat" "frontmatter scope"
equals "$(frontmatter_value description "$frontmatter_text")" "Retry Failed Chats." "frontmatter description"
equals "$(frontmatter_value empty "$frontmatter_text")" "" "frontmatter ignores null"

backlink_url="https://github.com/owner/repo/pull/123"
Expand Down Expand Up @@ -1047,16 +1049,23 @@ if [ "$(id -u)" -ne 0 ]; then
fi
ok "sync_spec_pr"

parse_work_item 'noise {"type":"feat","slug":"chat-retry","breaking":false}' || fail "parse_work_item failed"
parse_work_item 'noise {"type":"feat","scope":"chat","description":"retry failed chats","slug":"chat-retry","breaking":false}' || fail "parse_work_item failed"
equals "$WORK_TYPE" "feat" "work item type"
equals "$WORK_SLUG" "chat-retry" "work item slug"
equals "$WORK_BREAKING" "false" "work item breaking"
if parse_work_item '{"type":"feat","slug":"feat-chat-retry","breaking":false}' >/dev/null 2>&1; then fail "parse_work_item accepted type-prefixed slug"; fi
equals "$WORK_SCOPE" "chat" "work item scope"
equals "$WORK_DESCRIPTION" "retry failed chats" "work item description"
if parse_work_item '{"type":"feat","scope":"chat","description":"retry failed chats","slug":"feat-chat-retry","breaking":false}' >/dev/null 2>&1; then fail "parse_work_item accepted type-prefixed slug"; fi
if parse_work_item '{"type":"feat","slug":"chat-retry","breaking":false}' >/dev/null 2>&1; then fail "parse_work_item accepted missing PR title fields"; fi

equals "$(branch_base fix true null-check)" "fix!/null-check" "branch_base breaking"
equals "$(pass_commit_message feat false chat-retry 1)" "feat: chat-retry" "first pass commit"
equals "$(pass_commit_message feat false chat-retry 2)" "fix: chat-retry" "later pass commit"
equals "$(pass_commit_message chore false docs 2)" "chore: docs" "later chore commit"
equals "$(normalize_pr_description " Remove End Of Recording Delay. ")" "remove end of recording delay" "PR description normalization"
equals "$(pull_request_title feat false simulator "simulator result validation")" "feat(simulator): simulator result validation" "pull request title"
equals "$(pull_request_title fix false recording "remove end of recording delay")" "fix(recording): remove end of recording delay" "fix pull request title"
equals "$(pull_request_title feat true simulator "simulator result validation")" "feat(simulator)!: simulator result validation" "breaking pull request title"

branch_repo="$work/branch-repo"
git init -q "$branch_repo"
Expand Down Expand Up @@ -2885,7 +2894,7 @@ cat > "$fake_bin/codex" <<'AGENT'
set -euo pipefail
prompt="$(cat)"
if printf '%s\n' "$prompt" | grep -q "Work item naming task"; then
printf '%s\n' '{"type":"feat","slug":"fake-loop","breaking":false}'
printf '%s\n' '{"type":"feat","scope":"result","description":"write result file","slug":"fake-loop","breaking":false}'
exit 0
fi
pass="$(printf '%s\n' "$prompt" | sed -nE 's/^Pass: ([0-9]+).*/\1/p' | head -n 1)"
Expand Down Expand Up @@ -3040,6 +3049,8 @@ status: draft
type: feat
slug: $slug
breaking: false
scope: result
description: write result file
pr: null
---

Expand Down Expand Up @@ -3086,6 +3097,8 @@ PATH="$old_path"
equals "$WORK_TYPE" "feat" "naming fallback type override"
equals "$WORK_SLUG" "fake-loop" "naming fallback slug"
equals "$WORK_BREAKING" "false" "naming fallback breaking"
equals "$WORK_SCOPE" "result" "naming fallback scope"
equals "$WORK_DESCRIPTION" "write result file" "naming fallback description"
ok "naming fallback"

run_loop() {
Expand Down Expand Up @@ -3325,6 +3338,8 @@ create_line="$(grep -n 'gh pr create' "$pr_log" | cut -d: -f1 | head -n 1)"
review_line="$(grep -n 'agent reviewer 1' "$pr_log" | cut -d: -f1 | head -n 1)"
[[ -n "$create_line" && -n "$review_line" && "$create_line" -lt "$review_line" ]] || fail "PR was not created before reviewer pass 1"
contains "$(cat "$pr_log")" "--body-file" "PR create body flag"
contains "$(cat "$pr_log")" "--title feat(result): write result file" "PR create title"
not_contains "$(cat "$pr_log")" "--fill" "PR create skips commit title autofill"
[[ -s "$pr_state/pr-body.md" ]] || fail "created PR body missing"
pr_body="$(cat "$pr_state/pr-body.md")"
contains "$pr_body" "E2E PR Accept" "created PR body"
Expand Down
Loading