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
99 changes: 90 additions & 9 deletions .agents/scripts/issue-sync-helper.sh
Original file line number Diff line number Diff line change
Expand Up @@ -383,19 +383,95 @@ extract_plan_discoveries() {
# =============================================================================

# Find related PRD and task files in todo/tasks/
# Checks both grep matches and explicit ref:todo/tasks/ from the task line
find_related_files() {
local task_id="$1"
local project_root="$2"
local tasks_dir="$project_root/todo/tasks"
local todo_file="$project_root/TODO.md"
local all_files=""

# 1. Follow explicit ref:todo/tasks/ from the task line
if [[ -f "$todo_file" ]]; then
local task_line
task_line=$(grep -E "^- \[.\] ${task_id} " "$todo_file" | head -1 || echo "")
local explicit_refs
explicit_refs=$(echo "$task_line" | grep -oE 'ref:todo/tasks/[^ ]+' | sed 's/ref://' || true)
while IFS= read -r ref; do
if [[ -n "$ref" && -f "$project_root/$ref" ]]; then
all_files="${all_files:+$all_files"$'\n'"}$project_root/$ref"
fi
done <<< "$explicit_refs"
fi

# 2. Search for files referencing this task ID in todo/tasks/
if [[ -d "$tasks_dir" ]]; then
local grep_files
grep_files=$(grep -rl "$task_id" "$tasks_dir" 2>/dev/null || true)
if [[ -n "$grep_files" ]]; then
all_files="${all_files:+$all_files"$'\n'"}$grep_files"
fi
fi

if [[ ! -d "$tasks_dir" ]]; then
# Deduplicate
if [[ -n "$all_files" ]]; then
echo "$all_files" | sort -u
fi
return 0
}

# Extract a summary from a PRD or task file (first meaningful section, max 30 lines)
extract_file_summary() {
local file_path="$1"
local max_lines="${2:-30}"

if [[ ! -f "$file_path" ]]; then
return 0
fi

# Search for files referencing this task ID
local files
files=$(grep -rl "$task_id" "$tasks_dir" 2>/dev/null || true)
echo "$files"
local summary=""
local line_count=0
local in_content=false
local past_frontmatter=false

while IFS= read -r line; do
# Skip YAML frontmatter
if [[ "$line" == "---" ]] && [[ "$past_frontmatter" == "false" ]]; then
if [[ "$in_content" == "true" ]]; then
past_frontmatter=true
in_content=false
continue
fi
in_content=true
continue
fi
if [[ "$in_content" == "true" ]]; then
continue
fi
Comment on lines +437 to +450
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Malformed frontmatter (single ---) silently produces empty summary.

If a file contains an opening --- but no closing ---, in_content remains true for the entire file and every line is skipped, yielding an empty summary with no indication of why. This is a silent failure mode that could be confusing during debugging.

Consider adding a safeguard — e.g., if EOF is reached while in_content is still true, assume no frontmatter was present and re-read (or reset and reprocess).

As per coding guidelines, .agents/scripts/*.sh: "Reliability and robustness" and "Clear logging and feedback".

🤖 Prompt for AI Agents
In @.agents/scripts/issue-sync-helper.sh around lines 437 - 450, The parser can
silently skip the whole file if a starting frontmatter delimiter is never closed
because in_content remains "true"; update .agents/scripts/issue-sync-helper.sh
to detect this EOF state and recover: after the while IFS= read -r line loop
ends, check if in_content == "true" and if so log a warning about malformed
frontmatter, reset in_content and past_frontmatter to their default values, and
re-run the line-processing logic for the file (or re-open the file and
reprocess) so the file is treated as having no frontmatter rather than producing
an empty summary; reference the in_content and past_frontmatter variables and
the existing read loop in your fix.


# Skip empty lines at the start
if [[ -z "${line// /}" ]] && [[ $line_count -eq 0 ]]; then
continue
fi

# Skip the title heading (# Title)
if [[ $line_count -eq 0 ]] && echo "$line" | grep -qE '^# '; then
summary="$line"
line_count=1
continue
fi

summary="$summary"$'\n'"$line"
line_count=$((line_count + 1))

# Stop at max lines or at a major section break after getting some content
if [[ $line_count -ge $max_lines ]]; then
summary="$summary"$'\n'"..."
break
fi
done < "$file_path"

echo "$summary"
return 0
}

Expand Down Expand Up @@ -556,16 +632,21 @@ compose_issue_body() {
fi
fi

# Related PRD/task files
# Related PRD/task files (with inline content)
local related_files
related_files=$(find_related_files "$task_id" "$project_root")
if [[ -n "$related_files" ]]; then
body="$body"$'\n\n'"## Related Files"$'\n'
body="$body"$'\n\n'"## Related Files"
while IFS= read -r file; do
if [[ -n "$file" ]]; then
local rel_path
local rel_path file_summary
rel_path="${file#"$project_root"/}"
body="$body"$'\n'"- [\`$rel_path\`]($rel_path)"
file_summary=$(extract_file_summary "$file" 30)
if [[ -n "$file_summary" ]]; then
body="$body"$'\n\n'"<details><summary><code>$rel_path</code></summary>"$'\n\n'"$file_summary"$'\n\n'"</details>"
else
body="$body"$'\n\n'"- [\`$rel_path\`]($rel_path)"
fi
fi
done <<< "$related_files"
fi
Expand Down
268 changes: 268 additions & 0 deletions .github/workflows/issue-sync.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
name: Issue Sync - Bi-directional TODO.md ↔ GitHub Issues

on:
push:
branches: [main]
paths:
- 'TODO.md'
- 'todo/PLANS.md'
- 'todo/tasks/**'
issues:
types: [opened, closed, reopened, edited, labeled]
workflow_dispatch:
inputs:
command:
description: 'Sync command to run'
required: true
default: 'status'
type: choice
options:
- status
- pull
- push
- close
- enrich

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Comment on lines +26 to +28
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Concurrency group may cause missed syncs for issue events.

Both push and issues events on main resolve to the same github.ref (refs/heads/main), so they share a single concurrency group. With cancel-in-progress: true, rapid-fire issue events (or a push arriving during an issue sync) will cancel in-flight runs, potentially losing ref:GH# updates.

Consider scoping the concurrency group per job or per event type, e.g.:

Proposed fix
 concurrency:
-  group: ${{ github.workflow }}-${{ github.ref }}
+  group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
   cancel-in-progress: true

Alternatively, set cancel-in-progress: false if you'd prefer queued runs to complete rather than be cancelled.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
🤖 Prompt for AI Agents
In @.github/workflows/issue-sync.yml around lines 26 - 28, The current
concurrency block uses group: ${{ github.workflow }}-${{ github.ref }} with
cancel-in-progress: true which causes runs from different events (e.g., push and
issues) to share the same group and cancel each other; change the concurrency
grouping to include the event name or job identifier (e.g., use ${{
github.event_name }} or include matrix/job name) so issue-sync runs are scoped
separately from push runs, or alternatively set cancel-in-progress: false to
allow queued runs to complete; update the concurrency block (the group and
cancel-in-progress entries) accordingly to use ${{ github.event_name }} or a
per-job token to avoid canceling in-flight issue syncs.


jobs:
sync-on-push:
name: Sync TODO.md → GitHub Issues
if: github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
issues: write

steps:
- name: Check commit author
id: check-author
run: |

Check failure on line 43 in .github/workflows/issue-sync.yml

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

.github/workflows/issue-sync.yml#L43

Using variable interpolation `${{...}}` with `github` context data in a `run:` step could allow an attacker to inject their own code into the runner.
# Prevent infinite loops: skip if this commit was made by GitHub Actions
AUTHOR="${{ github.event.head_commit.author.name }}"
if [[ "$AUTHOR" == "GitHub Actions" ]] || [[ "$AUTHOR" == "github-actions[bot]" ]]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Skipping: commit was made by GitHub Actions (loop prevention)"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
Comment on lines +41 to +51
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Script injection vulnerability: github.event.head_commit.author.name interpolated directly in run: block.

Confirmed by actionlint and Codacy. A crafted commit author name (e.g., containing "; malicious_command; echo ") would execute arbitrary code in the runner. Pass it through an environment variable instead.

Proposed fix
      - name: Check commit author
        id: check-author
        run: |
          # Prevent infinite loops: skip if this commit was made by GitHub Actions
-          AUTHOR="${{ github.event.head_commit.author.name }}"
+          AUTHOR="$COMMIT_AUTHOR"
           if [[ "$AUTHOR" == "GitHub Actions" ]] || [[ "$AUTHOR" == "github-actions[bot]" ]]; then
             echo "skip=true" >> "$GITHUB_OUTPUT"
             echo "Skipping: commit was made by GitHub Actions (loop prevention)"
           else
             echo "skip=false" >> "$GITHUB_OUTPUT"
           fi
+        env:
+          COMMIT_AUTHOR: ${{ github.event.head_commit.author.name }}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Check commit author
id: check-author
run: |
# Prevent infinite loops: skip if this commit was made by GitHub Actions
AUTHOR="${{ github.event.head_commit.author.name }}"
if [[ "$AUTHOR" == "GitHub Actions" ]] || [[ "$AUTHOR" == "github-actions[bot]" ]]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Skipping: commit was made by GitHub Actions (loop prevention)"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Check commit author
id: check-author
run: |
# Prevent infinite loops: skip if this commit was made by GitHub Actions
AUTHOR="$COMMIT_AUTHOR"
if [[ "$AUTHOR" == "GitHub Actions" ]] || [[ "$AUTHOR" == "github-actions[bot]" ]]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Skipping: commit was made by GitHub Actions (loop prevention)"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
env:
COMMIT_AUTHOR: ${{ github.event.head_commit.author.name }}
🧰 Tools
🪛 actionlint (1.7.10)

[error] 43-43: "github.event.head_commit.author.name" is potentially untrusted. avoid using it directly in inline scripts. instead, pass it through an environment variable. see https://docs.github.com/en/actions/reference/security/secure-use#good-practices-for-mitigating-script-injection-attacks for more details

(expression)

🪛 GitHub Check: Codacy Static Code Analysis

[failure] 43-43: .github/workflows/issue-sync.yml#L43
Using variable interpolation ${{...}} with github context data in a run: step could allow an attacker to inject their own code into the runner.

🤖 Prompt for AI Agents
In @.github/workflows/issue-sync.yml around lines 41 - 51, The run block in the
"Check commit author" step (id: check-author) directly interpolates
github.event.head_commit.author.name into the shell, allowing script injection;
instead set AUTHOR via the step's env: (e.g., AUTHOR: ${{
github.event.head_commit.author.name }}) and then reference the safe $AUTHOR
inside the run script, ensuring you keep it quoted when compared (e.g., if [[
"$AUTHOR" == "GitHub Actions" ]] ...]) so the value is not re-parsed by the
shell.


- name: Checkout
if: steps.check-author.outputs.skip != 'true'
uses: actions/checkout@v4
with:
fetch-depth: 1
token: ${{ secrets.GITHUB_TOKEN }}

- name: Configure Git
if: steps.check-author.outputs.skip != 'true'
run: |
git config --global user.name "GitHub Actions"
git config --global user.email "actions@github.com"

- name: Close issues for completed tasks
if: steps.check-author.outputs.skip != 'true'
run: |
chmod +x .agents/scripts/issue-sync-helper.sh
chmod +x .agents/scripts/shared-constants.sh
echo "=== Closing issues for completed tasks ==="
bash .agents/scripts/issue-sync-helper.sh close --verbose || true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Push new tasks as issues
if: steps.check-author.outputs.skip != 'true'
run: |
echo "=== Creating issues for new tasks ==="
bash .agents/scripts/issue-sync-helper.sh push --verbose || true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Enrich plan-linked issues
if: steps.check-author.outputs.skip != 'true'
run: |
echo "=== Enriching plan-linked issues ==="
bash .agents/scripts/issue-sync-helper.sh enrich --verbose || true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Pull any missing refs back to TODO.md
if: steps.check-author.outputs.skip != 'true'
run: |
echo "=== Pulling issue refs to TODO.md ==="
bash .agents/scripts/issue-sync-helper.sh pull --verbose || true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Commit and push TODO.md updates
if: steps.check-author.outputs.skip != 'true'
run: |
if git diff --quiet TODO.md 2>/dev/null; then
echo "No TODO.md changes to commit"
else
git add TODO.md
git commit -m "chore: sync GitHub issue refs to TODO.md [skip ci]"
# Retry loop for concurrent pushes
for i in 1 2 3; do
echo "Push attempt $i..."
git pull --rebase origin main || true
if git push; then
echo "Push succeeded on attempt $i"
exit 0
fi
echo "Push failed, retrying..."
sleep $((i * 3))
done
echo "All push attempts failed"
exit 1
fi

- name: Show sync status
if: steps.check-author.outputs.skip != 'true'
run: |
bash .agents/scripts/issue-sync-helper.sh status || true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

sync-on-issue:
name: Sync GitHub Issue → TODO.md
if: github.event_name == 'issues'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
issues: read

steps:
- name: Check issue title format
id: check-issue
run: |
TITLE="${{ github.event.issue.title }}"
# Only sync issues with t-number prefix (our convention)
if echo "$TITLE" | grep -qE '^t[0-9]+'; then
echo "sync=true" >> "$GITHUB_OUTPUT"
TASK_ID=$(echo "$TITLE" | grep -oE '^t[0-9]+(\.[0-9]+)*')
echo "task_id=$TASK_ID" >> "$GITHUB_OUTPUT"
echo "Issue #${{ github.event.issue.number }} matches task $TASK_ID"
else
echo "sync=false" >> "$GITHUB_OUTPUT"
echo "Issue #${{ github.event.issue.number }} does not have t-number prefix, skipping"
fi
Comment on lines +140 to +153
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Script injection vulnerability: github.event.issue.title interpolated directly in run: block.

Same class of vulnerability as the commit author injection. Any GitHub user who can create an issue can inject arbitrary shell commands via a crafted issue title.

Proposed fix
      - name: Check issue title format
        id: check-issue
        run: |
-          TITLE="${{ github.event.issue.title }}"
+          TITLE="$ISSUE_TITLE"
           # Only sync issues with t-number prefix (our convention)
           if echo "$TITLE" | grep -qE '^t[0-9]+'; then
             echo "sync=true" >> "$GITHUB_OUTPUT"
             TASK_ID=$(echo "$TITLE" | grep -oE '^t[0-9]+(\.[0-9]+)*')
             echo "task_id=$TASK_ID" >> "$GITHUB_OUTPUT"
-            echo "Issue #${{ github.event.issue.number }} matches task $TASK_ID"
+            echo "Issue #${ISSUE_NUMBER} matches task $TASK_ID"
           else
             echo "sync=false" >> "$GITHUB_OUTPUT"
-            echo "Issue #${{ github.event.issue.number }} does not have t-number prefix, skipping"
+            echo "Issue #${ISSUE_NUMBER} does not have t-number prefix, skipping"
           fi
+        env:
+          ISSUE_TITLE: ${{ github.event.issue.title }}
+          ISSUE_NUMBER: ${{ github.event.issue.number }}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Check issue title format
id: check-issue
run: |
TITLE="${{ github.event.issue.title }}"
# Only sync issues with t-number prefix (our convention)
if echo "$TITLE" | grep -qE '^t[0-9]+'; then
echo "sync=true" >> "$GITHUB_OUTPUT"
TASK_ID=$(echo "$TITLE" | grep -oE '^t[0-9]+(\.[0-9]+)*')
echo "task_id=$TASK_ID" >> "$GITHUB_OUTPUT"
echo "Issue #${{ github.event.issue.number }} matches task $TASK_ID"
else
echo "sync=false" >> "$GITHUB_OUTPUT"
echo "Issue #${{ github.event.issue.number }} does not have t-number prefix, skipping"
fi
- name: Check issue title format
id: check-issue
run: |
TITLE="$ISSUE_TITLE"
# Only sync issues with t-number prefix (our convention)
if echo "$TITLE" | grep -qE '^t[0-9]+'; then
echo "sync=true" >> "$GITHUB_OUTPUT"
TASK_ID=$(echo "$TITLE" | grep -oE '^t[0-9]+(\.[0-9]+)*')
echo "task_id=$TASK_ID" >> "$GITHUB_OUTPUT"
echo "Issue #${ISSUE_NUMBER} matches task $TASK_ID"
else
echo "sync=false" >> "$GITHUB_OUTPUT"
echo "Issue #${ISSUE_NUMBER} does not have t-number prefix, skipping"
fi
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
🧰 Tools
🪛 actionlint (1.7.10)

[error] 142-142: "github.event.issue.title" is potentially untrusted. avoid using it directly in inline scripts. instead, pass it through an environment variable. see https://docs.github.com/en/actions/reference/security/secure-use#good-practices-for-mitigating-script-injection-attacks for more details

(expression)

🤖 Prompt for AI Agents
In @.github/workflows/issue-sync.yml around lines 140 - 153, The step "Check
issue title format" is currently interpolating github.event.issue.title directly
into the run: block (variable TITLE="${{ github.event.issue.title }}"), which
allows script injection; fix it by moving the issue title into a safe
environment variable and always expand it quoted inside the shell: in the
check-issue step set env: ISSUE_TITLE: ${{ github.event.issue.title }} and in
the run block use TITLE="$ISSUE_TITLE" (always quote expansions like "$TITLE"
and "$ISSUE_TITLE"), then perform the pattern checks and TASK_ID extraction from
the quoted variable (e.g., echo "$TITLE" | grep -qE '^t[0-9]+' and
TASK_ID=$(echo "$TITLE" | grep -oE '^t[0-9]+(\.[0-9]+)*')), and use printf '%s'
"$TITLE" when printing to avoid executing content.


- name: Checkout
if: steps.check-issue.outputs.sync == 'true'
uses: actions/checkout@v4
with:
fetch-depth: 1
token: ${{ secrets.GITHUB_TOKEN }}

- name: Configure Git
if: steps.check-issue.outputs.sync == 'true'
run: |
git config --global user.name "GitHub Actions"
git config --global user.email "actions@github.com"

- name: Sync issue ref to TODO.md
if: steps.check-issue.outputs.sync == 'true'
run: |
chmod +x .agents/scripts/issue-sync-helper.sh
chmod +x .agents/scripts/shared-constants.sh

TASK_ID="${{ steps.check-issue.outputs.task_id }}"
ISSUE_NUM="${{ github.event.issue.number }}"
ACTION="${{ github.event.action }}"

echo "=== Issue event: $ACTION for $TASK_ID (#$ISSUE_NUM) ==="

case "$ACTION" in
opened)
# Pull the new issue ref into TODO.md
bash .agents/scripts/issue-sync-helper.sh pull --verbose || true
;;
closed)
# If issue was closed, check if TODO.md task should be marked done
echo "Issue #$ISSUE_NUM closed. Run 'issue-sync-helper.sh status' to check drift."
;;
reopened)
echo "Issue #$ISSUE_NUM reopened. Manual TODO.md update may be needed."
;;
edited|labeled)
# Re-enrich if the issue was edited (might need label sync)
echo "Issue #$ISSUE_NUM edited/labeled. No automatic TODO.md update."
;;
esac
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Commit and push TODO.md updates
if: steps.check-issue.outputs.sync == 'true'
run: |
if git diff --quiet TODO.md 2>/dev/null; then
echo "No TODO.md changes to commit"
else
git add TODO.md
git commit -m "chore: sync ref:GH#${{ github.event.issue.number }} to TODO.md [skip ci]"
for i in 1 2 3; do
echo "Push attempt $i..."
git pull --rebase origin main || true
if git push; then
echo "Push succeeded on attempt $i"
exit 0
fi
sleep $((i * 3))
done
echo "All push attempts failed"
exit 1
fi

manual-sync:
name: Manual Sync
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
issues: write

steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
token: ${{ secrets.GITHUB_TOKEN }}

- name: Configure Git
run: |
git config --global user.name "GitHub Actions"
git config --global user.email "actions@github.com"

- name: Run sync command
run: |
chmod +x .agents/scripts/issue-sync-helper.sh
chmod +x .agents/scripts/shared-constants.sh
COMMAND="${{ github.event.inputs.command }}"
echo "=== Running: issue-sync-helper.sh $COMMAND ==="
bash .agents/scripts/issue-sync-helper.sh "$COMMAND" --verbose
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Commit and push TODO.md updates
if: github.event.inputs.command == 'pull' || github.event.inputs.command == 'push'
run: |
if git diff --quiet TODO.md 2>/dev/null; then
echo "No TODO.md changes to commit"
else
git add TODO.md
git commit -m "chore: manual issue sync (${{ github.event.inputs.command }}) [skip ci]"
for i in 1 2 3; do
git pull --rebase origin main || true
if git push; then
exit 0
fi
sleep $((i * 3))
done
exit 1
fi
Loading