Compare commits
1 Commits
fix/1430
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c66292727e |
@@ -1,49 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Commit-msg hook: warn about shell injection risks
|
||||
# Install: cp .githooks/commit-msg .git/hooks/commit-msg && chmod +x .git/hooks/commit-msg
|
||||
|
||||
COMMIT_MSG_FILE="$1"
|
||||
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
|
||||
|
||||
# Check for dangerous patterns
|
||||
DANGEROUS_PATTERNS=(
|
||||
'`' # Backticks
|
||||
'$(' # Command substitution
|
||||
'${' # Variable expansion
|
||||
'\\`' # Escaped backticks
|
||||
'eval ' # eval command
|
||||
'exec ' # exec command
|
||||
'source ' # source command
|
||||
'|' # Pipe
|
||||
'&&' # AND operator
|
||||
'||' # OR operator
|
||||
';' # Semicolon
|
||||
'>' # Redirect
|
||||
'<' # Input redirect
|
||||
)
|
||||
|
||||
FOUND_ISSUES=()
|
||||
for pattern in "${DANGEROUS_PATTERNS[@]}"; do
|
||||
if echo "$COMMIT_MSG" | grep -q "$pattern"; then
|
||||
FOUND_ISSUES+=("$pattern")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#FOUND_ISSUES[@]} -gt 0 ]; then
|
||||
echo "⚠️ WARNING: Commit message contains potentially dangerous patterns:"
|
||||
for issue in "${FOUND_ISSUES[@]}"; do
|
||||
echo " - $issue"
|
||||
done
|
||||
echo ""
|
||||
echo "This could trigger shell execution during git operations."
|
||||
echo ""
|
||||
echo "Safe alternatives:"
|
||||
echo " 1. Use: git commit -F <file> instead of git commit -m"
|
||||
echo " 2. Escape special characters in commit messages"
|
||||
echo " 3. Use the safe_commit() function from bin/safe_commit.py"
|
||||
echo ""
|
||||
echo "To proceed anyway, use: git commit --no-verify"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,307 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Safe commit message handler to prevent shell injection.
|
||||
|
||||
Issue #1430: [IMPROVEMENT] memory_mine.py ran during git commit — shell injection from commit message
|
||||
|
||||
This script provides safe ways to commit with code-containing messages.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import tempfile
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def escape_shell_chars(text: str) -> str:
|
||||
"""
|
||||
Escape shell-sensitive characters in text.
|
||||
|
||||
This prevents shell injection when text is used in shell commands.
|
||||
"""
|
||||
# Characters that need escaping in shell
|
||||
shell_chars = ['$', '`', '\\', '"', "'", '!', '(', ')', '{', '}', '[', ']',
|
||||
'|', '&', ';', '<', '>', '*', '?', '~', '#']
|
||||
|
||||
escaped = text
|
||||
for char in shell_chars:
|
||||
escaped = escaped.replace(char, '\\' + char)
|
||||
|
||||
return escaped
|
||||
|
||||
|
||||
def safe_commit_message(message: str) -> str:
|
||||
"""
|
||||
Create a safe commit message by escaping shell-sensitive characters.
|
||||
|
||||
Args:
|
||||
message: The commit message
|
||||
|
||||
Returns:
|
||||
Escaped commit message safe for shell use
|
||||
"""
|
||||
return escape_shell_chars(message)
|
||||
|
||||
|
||||
def commit_with_file(message: str, branch: str = None) -> bool:
|
||||
"""
|
||||
Commit using a temporary file instead of -m flag.
|
||||
|
||||
This is the safest way to commit messages containing code or special characters.
|
||||
|
||||
Args:
|
||||
message: The commit message
|
||||
branch: Optional branch name
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
# Create temporary file for commit message
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
|
||||
f.write(message)
|
||||
temp_file = f.name
|
||||
|
||||
try:
|
||||
# Build git command
|
||||
cmd = ['git', 'commit', '-F', temp_file]
|
||||
if branch:
|
||||
cmd.extend(['-b', branch])
|
||||
|
||||
# Execute git commit
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode == 0:
|
||||
print(f"✅ Committed successfully using file: {temp_file}")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ Commit failed: {result.stderr}")
|
||||
return False
|
||||
|
||||
finally:
|
||||
# Clean up temporary file
|
||||
try:
|
||||
os.unlink(temp_file)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
def commit_safe(message: str, use_file: bool = True) -> bool:
|
||||
"""
|
||||
Safely commit with a message.
|
||||
|
||||
Args:
|
||||
message: The commit message
|
||||
use_file: If True, use -F <file> instead of -m
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if use_file:
|
||||
return commit_with_file(message)
|
||||
else:
|
||||
# Use escaped message with -m flag
|
||||
escaped_message = safe_commit_message(message)
|
||||
cmd = ['git', 'commit', '-m', escaped_message]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode == 0:
|
||||
print("✅ Committed successfully with escaped message")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ Commit failed: {result.stderr}")
|
||||
return False
|
||||
|
||||
|
||||
def check_commit_message_safety(message: str) -> dict:
|
||||
"""
|
||||
Check if a commit message contains potentially dangerous patterns.
|
||||
|
||||
Args:
|
||||
message: The commit message to check
|
||||
|
||||
Returns:
|
||||
Dictionary with safety analysis
|
||||
"""
|
||||
dangerous_patterns = [
|
||||
(r'`[^`]*`', 'Backticks (shell command substitution)'),
|
||||
(r'\$\([^)]*\)', 'Command substitution $(...)'),
|
||||
(r'\$\{[^}]*\}', 'Variable expansion ${...}'),
|
||||
(r'\\`', 'Escaped backticks'),
|
||||
(r'eval\s+', 'eval command'),
|
||||
(r'exec\s+', 'exec command'),
|
||||
(r'source\s+', 'source command'),
|
||||
(r'\.\s+', 'dot command'),
|
||||
(r'\|\s*', 'Pipe character'),
|
||||
(r'&&', 'AND operator'),
|
||||
(r'\|\|', 'OR operator'),
|
||||
(r';', 'Semicolon (command separator)'),
|
||||
(r'>', 'Redirect operator'),
|
||||
(r'<', 'Input redirect'),
|
||||
]
|
||||
|
||||
findings = []
|
||||
for pattern, description in dangerous_patterns:
|
||||
matches = re.findall(pattern, message)
|
||||
if matches:
|
||||
findings.append({
|
||||
'pattern': pattern,
|
||||
'description': description,
|
||||
'matches': matches,
|
||||
'count': len(matches)
|
||||
})
|
||||
|
||||
return {
|
||||
'safe': len(findings) == 0,
|
||||
'findings': findings,
|
||||
'recommendation': 'Use commit_with_file() or escape_shell_chars()' if findings else 'Message appears safe'
|
||||
}
|
||||
|
||||
|
||||
def create_commit_hook_guard():
|
||||
"""
|
||||
Create a commit-msg hook that warns about dangerous patterns.
|
||||
"""
|
||||
hook_content = '''#!/usr/bin/env bash
|
||||
# Commit-msg hook: warn about shell injection risks
|
||||
# Install: cp .githooks/commit-msg .git/hooks/commit-msg && chmod +x .git/hooks/commit-msg
|
||||
|
||||
COMMIT_MSG_FILE="$1"
|
||||
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
|
||||
|
||||
# Check for dangerous patterns
|
||||
DANGEROUS_PATTERNS=(
|
||||
'`' # Backticks
|
||||
'$(' # Command substitution
|
||||
'${' # Variable expansion
|
||||
'\\`' # Escaped backticks
|
||||
'eval ' # eval command
|
||||
'exec ' # exec command
|
||||
'source ' # source command
|
||||
'|' # Pipe
|
||||
'&&' # AND operator
|
||||
'||' # OR operator
|
||||
';' # Semicolon
|
||||
'>' # Redirect
|
||||
'<' # Input redirect
|
||||
)
|
||||
|
||||
FOUND_ISSUES=()
|
||||
for pattern in "${DANGEROUS_PATTERNS[@]}"; do
|
||||
if echo "$COMMIT_MSG" | grep -q "$pattern"; then
|
||||
FOUND_ISSUES+=("$pattern")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#FOUND_ISSUES[@]} -gt 0 ]; then
|
||||
echo "⚠️ WARNING: Commit message contains potentially dangerous patterns:"
|
||||
for issue in "${FOUND_ISSUES[@]}"; do
|
||||
echo " - $issue"
|
||||
done
|
||||
echo ""
|
||||
echo "This could trigger shell execution during git operations."
|
||||
echo ""
|
||||
echo "Safe alternatives:"
|
||||
echo " 1. Use: git commit -F <file> instead of git commit -m"
|
||||
echo " 2. Escape special characters in commit messages"
|
||||
echo " 3. Use the safe_commit() function from bin/safe_commit.py"
|
||||
echo ""
|
||||
echo "To proceed anyway, use: git commit --no-verify"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
'''
|
||||
|
||||
return hook_content
|
||||
|
||||
|
||||
def install_commit_hook():
|
||||
"""
|
||||
Install the commit-msg hook to warn about dangerous patterns.
|
||||
"""
|
||||
hook_path = Path('.git/hooks/commit-msg')
|
||||
hook_content = create_commit_hook_guard()
|
||||
|
||||
# Check if .git/hooks exists
|
||||
if not hook_path.parent.exists():
|
||||
print("❌ .git/hooks directory not found")
|
||||
return False
|
||||
|
||||
# Write hook
|
||||
with open(hook_path, 'w') as f:
|
||||
f.write(hook_content)
|
||||
|
||||
# Make executable
|
||||
os.chmod(hook_path, 0o755)
|
||||
|
||||
print(f"✅ Installed commit-msg hook to {hook_path}")
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for safe commit tool."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Safe commit message handling")
|
||||
parser.add_argument("--message", "-m", help="Commit message")
|
||||
parser.add_argument("--file", "-F", help="Read commit message from file")
|
||||
parser.add_argument("--check", action="store_true", help="Check message safety")
|
||||
parser.add_argument("--install-hook", action="store_true", help="Install commit-msg hook")
|
||||
parser.add_argument("--escape", action="store_true", help="Escape shell characters in message")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.install_hook:
|
||||
if install_commit_hook():
|
||||
print("Commit hook installed successfully")
|
||||
else:
|
||||
print("Failed to install commit hook")
|
||||
sys.exit(1)
|
||||
return
|
||||
|
||||
if args.check:
|
||||
if args.message:
|
||||
safety = check_commit_message_safety(args.message)
|
||||
print(f"Message safety check:")
|
||||
print(f" Safe: {safety['safe']}")
|
||||
print(f" Recommendation: {safety['recommendation']}")
|
||||
if safety['findings']:
|
||||
print(f" Findings:")
|
||||
for finding in safety['findings']:
|
||||
print(f" - {finding['description']}: {finding['count']} matches")
|
||||
else:
|
||||
print("Please provide a message with --message")
|
||||
return
|
||||
|
||||
if args.escape:
|
||||
if args.message:
|
||||
escaped = safe_commit_message(args.message)
|
||||
print(f"Escaped message:")
|
||||
print(escaped)
|
||||
else:
|
||||
print("Please provide a message with --message")
|
||||
return
|
||||
|
||||
if args.file:
|
||||
# Read message from file
|
||||
with open(args.file, 'r') as f:
|
||||
message = f.read()
|
||||
commit_with_file(message)
|
||||
elif args.message:
|
||||
# Check if message has dangerous patterns
|
||||
safety = check_commit_message_safety(args.message)
|
||||
if safety['safe']:
|
||||
commit_safe(args.message, use_file=False)
|
||||
else:
|
||||
print("⚠️ Message contains potentially dangerous patterns")
|
||||
print("Using file-based commit for safety...")
|
||||
commit_safe(args.message, use_file=True)
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,159 +0,0 @@
|
||||
# Safe Commit Practices
|
||||
|
||||
**Issue:** #1430 - [IMPROVEMENT] memory_mine.py ran during git commit — shell injection from commit message
|
||||
|
||||
## Problem
|
||||
|
||||
During commit for #1124, the commit message contained Python code examples that triggered shell execution of memory_mine.py. The backtick-wrapped code in the commit message was interpreted by the shell during git commit processing.
|
||||
|
||||
This is a potential vector for unintended code execution.
|
||||
|
||||
## Safe Commit Methods
|
||||
|
||||
### 1. Use `git commit -F <file>` (Recommended)
|
||||
|
||||
The safest way to commit messages containing code or special characters:
|
||||
|
||||
```bash
|
||||
# Create a file with your commit message
|
||||
echo "Fix: implement memory_mine.py with backtick example
|
||||
|
||||
Example: \`python3 bin/memory_mine.py --days 7\`
|
||||
|
||||
This commit adds memory mining functionality." > /tmp/commit-msg.txt
|
||||
|
||||
# Commit using the file
|
||||
git commit -F /tmp/commit-msg.txt
|
||||
```
|
||||
|
||||
### 2. Use the Safe Commit Tool
|
||||
|
||||
```bash
|
||||
# Safe commit with automatic escaping
|
||||
python3 bin/safe_commit.py -m "Fix: implement memory_mine.py with backtick example"
|
||||
|
||||
# Safe commit using file
|
||||
python3 bin/safe_commit.py -F /tmp/commit-msg.txt
|
||||
|
||||
# Check if a message is safe
|
||||
python3 bin/safe_commit.py --check -m "Example: \`python3 bin/memory_mine.py\`"
|
||||
```
|
||||
|
||||
### 3. Escape Shell Characters Manually
|
||||
|
||||
If you must use `git commit -m`, escape special characters:
|
||||
|
||||
```bash
|
||||
# Escape backticks and other shell characters
|
||||
git commit -m "Fix: implement memory_mine.py with backtick example
|
||||
|
||||
Example: \\`python3 bin/memory_mine.py --days 7\\`
|
||||
|
||||
This commit adds memory mining functionality."
|
||||
```
|
||||
|
||||
## Dangerous Patterns to Avoid
|
||||
|
||||
The following patterns in commit messages can trigger shell execution:
|
||||
|
||||
- **Backticks**: `` `command` `` → Executes command
|
||||
- **Command substitution**: `$(command)` → Executes command
|
||||
- **Variable expansion**: `${variable}` → Expands variable
|
||||
- **Pipes**: `command1 | command2` → Pipes output
|
||||
- **Operators**: `&&`, `||`, `;` → Command chaining
|
||||
- **Redirects**: `>`, `<` → File operations
|
||||
|
||||
## Installation
|
||||
|
||||
### Install the Commit Hook
|
||||
|
||||
To automatically warn about dangerous patterns:
|
||||
|
||||
```bash
|
||||
# Install the commit-msg hook
|
||||
python3 bin/safe_commit.py --install-hook
|
||||
|
||||
# Or manually
|
||||
cp .githooks/commit-msg .git/hooks/commit-msg
|
||||
chmod +x .git/hooks/commit-msg
|
||||
```
|
||||
|
||||
### Configure Git Hooks Path
|
||||
|
||||
If using the `.githooks` directory:
|
||||
|
||||
```bash
|
||||
git config core.hooksPath .githooks
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Dangerous (Don't do this)
|
||||
|
||||
```bash
|
||||
# This could trigger shell execution
|
||||
git commit -m "Fix: implement memory_mine.py
|
||||
|
||||
Example: \`python3 bin/memory_mine.py --days 7\`
|
||||
|
||||
This mines sessions into MemPalace."
|
||||
```
|
||||
|
||||
### ✅ Safe (Do this instead)
|
||||
|
||||
```bash
|
||||
# Method 1: Use file
|
||||
echo "Fix: implement memory_mine.py
|
||||
|
||||
Example: \`python3 bin/memory_mine.py --days 7\`
|
||||
|
||||
This mines sessions into MemPalace." > /tmp/commit-msg.txt
|
||||
git commit -F /tmp/commit-msg.txt
|
||||
|
||||
# Method 2: Use safe commit tool
|
||||
python3 bin/safe_commit.py -m "Fix: implement memory_mine.py
|
||||
|
||||
Example: \`python3 bin/memory_mine.py --days 7\`
|
||||
|
||||
This mines sessions into MemPalace."
|
||||
|
||||
# Method 3: Escape manually
|
||||
git commit -m "Fix: implement memory_mine.py
|
||||
|
||||
Example: \\`python3 bin/memory_mine.py --days 7\\`
|
||||
|
||||
This mines sessions into MemPalace."
|
||||
```
|
||||
|
||||
## What Happened in Issue #1430
|
||||
|
||||
During commit for #1124, a commit message contained:
|
||||
```
|
||||
Example: \`python3 bin/memory_mine.py --days 7\`
|
||||
```
|
||||
|
||||
The backticks were interpreted by the shell during git commit processing, causing memory_mine.py to execute. While the outcome was positive (26 sessions mined), this is a security risk.
|
||||
|
||||
## Prevention
|
||||
|
||||
1. **Always use `git commit -F <file>`** for messages containing code
|
||||
2. **Install the commit-msg hook** to warn about dangerous patterns
|
||||
3. **Use the safe_commit.py tool** for automatic escaping
|
||||
4. **Document safe patterns** in team guidelines
|
||||
|
||||
## Related Issues
|
||||
|
||||
- **Issue #1430:** This improvement
|
||||
- **Issue #1124:** Original issue that triggered the problem
|
||||
|
||||
## Files
|
||||
|
||||
- `bin/safe_commit.py` - Safe commit tool
|
||||
- `.githooks/commit-msg` - Commit hook (to be installed)
|
||||
- `docs/safe-commit-practices.md` - This documentation
|
||||
|
||||
## Conclusion
|
||||
|
||||
Shell injection in commit messages is a real security risk. By using safe commit practices, we can prevent unintended code execution while still allowing code examples in commit messages.
|
||||
|
||||
**Remember:** When in doubt, use `git commit -F <file>` instead of `git commit -m`.
|
||||
135
scripts/claim-issue.sh
Executable file
135
scripts/claim-issue.sh
Executable file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env bash
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# claim-issue.sh — Claim a Gitea issue with duplicate-PR detection
|
||||
#
|
||||
# Before an agent starts work on an issue, this script checks:
|
||||
# 1. Is the issue already assigned?
|
||||
# 2. Do open PRs already reference this issue?
|
||||
# 3. Is the issue closed?
|
||||
#
|
||||
# Only proceeds to assign if all checks pass.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/claim-issue.sh <issue_number> [repo] [assignee]
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 — Claimed successfully
|
||||
# 1 — BLOCKED (duplicate PR exists, already assigned, or issue closed)
|
||||
# 2 — Error (missing args, API failure)
|
||||
#
|
||||
# Issue #1492: Duplicate-PR detection in agent claim workflow.
|
||||
# Issue #1480: The meta-problem this prevents.
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
set -euo pipefail
|
||||
|
||||
ISSUE_NUM="${1:-}"
|
||||
REPO="${2:-Timmy_Foundation/the-nexus}"
|
||||
ASSIGNEE="${3:-timmy}"
|
||||
|
||||
if [ -z "$ISSUE_NUM" ]; then
|
||||
echo "Usage: $0 <issue_number> [repo] [assignee]"
|
||||
echo "Example: $0 1128"
|
||||
echo " $0 1339 Timmy_Foundation/the-nexus allegro"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
GITEA_URL="${GITEA_URL:-https://forge.alexanderwhitestone.com}"
|
||||
GITEA_TOKEN="${GITEA_TOKEN:-}"
|
||||
if [ -z "$GITEA_TOKEN" ]; then
|
||||
TOKEN_FILE="${HOME}/.config/gitea/token"
|
||||
if [ -f "$TOKEN_FILE" ]; then
|
||||
GITEA_TOKEN=$(cat "$TOKEN_FILE" | tr -d '[:space:]')
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$GITEA_TOKEN" ]; then
|
||||
echo "Error: No GITEA_TOKEN. Set env var or create ~/.config/gitea/token"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
API="$GITEA_URL/api/v1"
|
||||
AUTH="Authorization: token $GITEA_TOKEN"
|
||||
|
||||
log() { echo "[$(date -u +%H:%M:%S)] $*"; }
|
||||
|
||||
echo "═══ Claim Issue #$ISSUE_NUM ═══"
|
||||
echo ""
|
||||
|
||||
# ── Step 1: Fetch the issue ──────────────────────────────────
|
||||
ISSUE=$(curl -s -H "$AUTH" "$API/repos/$REPO/issues/$ISSUE_NUM")
|
||||
|
||||
if echo "$ISSUE" | jq -e '.message' > /dev/null 2>&1; then
|
||||
ERROR=$(echo "$ISSUE" | jq -r '.message')
|
||||
echo "✗ Error fetching issue: $ERROR"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
ISSUE_STATE=$(echo "$ISSUE" | jq -r '.state')
|
||||
ISSUE_TITLE=$(echo "$ISSUE" | jq -r '.title')
|
||||
ISSUE_ASSIGNEES=$(echo "$ISSUE" | jq -r '.assignees // [] | map(.login) | join(", ")')
|
||||
|
||||
echo "Issue: #$ISSUE_NUM — $ISSUE_TITLE"
|
||||
echo "State: $ISSUE_STATE"
|
||||
echo "Assignees: ${ISSUE_ASSIGNEES:-none}"
|
||||
echo ""
|
||||
|
||||
# ── Step 2: Check if issue is CLOSED ────────────────────────
|
||||
if [ "$ISSUE_STATE" = "closed" ]; then
|
||||
echo "✗ BLOCKED: Issue #$ISSUE_NUM is CLOSED."
|
||||
echo " Do not work on closed issues."
|
||||
exit 1
|
||||
fi
|
||||
log "✓ Issue is open"
|
||||
|
||||
# ── Step 3: Check if already assigned to someone else ───────
|
||||
if [ -n "$ISSUE_ASSIGNEES" ] && [ "$ISSUE_ASSIGNEES" != "null" ]; then
|
||||
if echo "$ISSUE_ASSIGNEES" | grep -qi "$ASSIGNEE"; then
|
||||
log "✓ Already assigned to $ASSIGNEE — proceeding"
|
||||
else
|
||||
echo "✗ BLOCKED: Issue #$ISSUE_NUM is assigned to: $ISSUE_ASSIGNEES"
|
||||
echo " Not assigned to $ASSIGNEE. Do not work on others' issues."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
log "✓ Issue is unassigned"
|
||||
fi
|
||||
|
||||
# ── Step 4: Check for existing open PRs ─────────────────────
|
||||
OPEN_PRS=$(curl -s -H "$AUTH" "$API/repos/$REPO/pulls?state=open&limit=100")
|
||||
|
||||
ISSUE_STR="#$ISSUE_NUM"
|
||||
DUPLICATES=$(echo "$OPEN_PRS" | jq -r ".[] | select(.title | test(\"$ISSUE_STR\"; \"i\") or (.body // \"\") | test(\"$ISSUE_STR\"; \"i\")) | \" PR #\\(.number): \\(.title) [\\(.head.ref)] (\\(.created_at[:10]))\"")
|
||||
|
||||
if [ -n "$DUPLICATES" ]; then
|
||||
echo "✗ BLOCKED: Open PRs already exist for issue #$ISSUE_NUM:"
|
||||
echo ""
|
||||
echo "$DUPLICATES"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " 1. Review and merge an existing PR"
|
||||
echo " 2. Close duplicates: ./scripts/cleanup-duplicate-prs.sh --close"
|
||||
echo " 3. Push to an existing branch"
|
||||
echo ""
|
||||
echo "Do NOT create a new PR. See #1492."
|
||||
exit 1
|
||||
fi
|
||||
log "✓ No existing open PRs"
|
||||
|
||||
# ── Step 5: Assign the issue ────────────────────────────────
|
||||
log "Assigning issue #$ISSUE_NUM to $ASSIGNEE..."
|
||||
|
||||
ASSIGN_RESULT=$(curl -s -X POST -H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d "{\"assignees\":[\"$ASSIGNEE\"]}" \
|
||||
"$API/repos/$REPO/issues/$ISSUE_NUM/assignees")
|
||||
|
||||
if echo "$ASSIGN_RESULT" | jq -e '.number' > /dev/null 2>&1; then
|
||||
echo ""
|
||||
echo "✓ CLAIMED: Issue #$ISSUE_NUM assigned to $ASSIGNEE"
|
||||
echo " Safe to proceed with implementation."
|
||||
exit 0
|
||||
else
|
||||
ERROR=$(echo "$ASSIGN_RESULT" | jq -r '.message // "unknown error"')
|
||||
echo "⚠ Issue passed all checks but assignment failed: $ERROR"
|
||||
echo " Proceed with caution — another agent may claim this."
|
||||
exit 0
|
||||
fi
|
||||
135
scripts/claim_issue.py
Normal file
135
scripts/claim_issue.py
Normal file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
claim_issue.py — Claim a Gitea issue with duplicate-PR detection.
|
||||
|
||||
Before an agent starts work, checks:
|
||||
1. Is the issue open?
|
||||
2. Is it already assigned to someone else?
|
||||
3. Do open PRs already reference this issue?
|
||||
|
||||
Only assigns if all checks pass.
|
||||
|
||||
Usage:
|
||||
python3 scripts/claim_issue.py 1492
|
||||
python3 scripts/claim_issue.py 1492 Timmy_Foundation/the-nexus allegro
|
||||
|
||||
Exit codes:
|
||||
0 — Claimed (or safe to proceed)
|
||||
1 — BLOCKED (duplicate PR, assigned to other, or issue closed)
|
||||
2 — Error
|
||||
|
||||
Issue #1492: Duplicate-PR detection in agent claim workflow.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
|
||||
def claim_issue(issue_num: int, repo: str = "Timmy_Foundation/the-nexus",
|
||||
assignee: str = "timmy", token: str = None) -> dict:
|
||||
"""Claim an issue with duplicate-PR detection.
|
||||
|
||||
Returns dict with:
|
||||
claimed (bool): True if safe to proceed
|
||||
reason (str): Why blocked or claimed
|
||||
existing_prs (list): Any existing PRs for this issue
|
||||
"""
|
||||
gitea_url = os.environ.get("GITEA_URL", "https://forge.alexanderwhitestone.com")
|
||||
token = token or os.environ.get("GITEA_TOKEN", "")
|
||||
if not token:
|
||||
token_path = os.path.expanduser("~/.config/gitea/token")
|
||||
if os.path.exists(token_path):
|
||||
token = open(token_path).read().strip()
|
||||
|
||||
if not token:
|
||||
return {"claimed": False, "reason": "No GITEA_TOKEN", "existing_prs": []}
|
||||
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
api = f"{gitea_url}/api/v1/repos/{repo}"
|
||||
|
||||
# Fetch issue
|
||||
try:
|
||||
req = urllib.request.Request(f"{api}/issues/{issue_num}", headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
issue = json.loads(resp.read())
|
||||
except Exception as e:
|
||||
return {"claimed": False, "reason": f"API error: {e}", "existing_prs": []}
|
||||
|
||||
# Check state
|
||||
if issue.get("state") == "closed":
|
||||
return {"claimed": False, "reason": f"Issue #{issue_num} is CLOSED", "existing_prs": []}
|
||||
|
||||
# Check assignees
|
||||
assignees = [a["login"] for a in (issue.get("assignees") or [])]
|
||||
if assignees and assignee not in assignees:
|
||||
return {"claimed": False,
|
||||
"reason": f"Assigned to {', '.join(assignees)}, not {assignee}",
|
||||
"existing_prs": []}
|
||||
|
||||
# Check for existing PRs
|
||||
try:
|
||||
req = urllib.request.Request(f"{api}/pulls?state=open&limit=100", headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
prs = json.loads(resp.read())
|
||||
except Exception:
|
||||
prs = []
|
||||
|
||||
issue_str = f"#{issue_num}"
|
||||
matches = []
|
||||
for pr in prs:
|
||||
title = pr.get("title", "")
|
||||
body = pr.get("body") or ""
|
||||
if issue_str in title or issue_str in body:
|
||||
matches.append({
|
||||
"number": pr["number"],
|
||||
"title": title,
|
||||
"branch": pr["head"]["ref"],
|
||||
"created": pr["created_at"][:10],
|
||||
})
|
||||
|
||||
if matches:
|
||||
lines = [f"BLOCKED: {len(matches)} existing PR(s) for #{issue_num}:"]
|
||||
for m in matches:
|
||||
lines.append(f" PR #{m['number']}: {m['title']} [{m['branch']}]")
|
||||
return {"claimed": False, "reason": "\n".join(lines), "existing_prs": matches}
|
||||
|
||||
# All checks passed — assign
|
||||
try:
|
||||
data = json.dumps({"assignees": [assignee]}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{api}/issues/{issue_num}/assignees",
|
||||
data=data, headers={**headers, "Content-Type": "application/json"},
|
||||
method="POST"
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
return {"claimed": True,
|
||||
"reason": f"Issue #{issue_num} claimed by {assignee}",
|
||||
"existing_prs": []}
|
||||
except Exception as e:
|
||||
return {"claimed": True,
|
||||
"reason": f"Checks passed but assignment failed: {e}",
|
||||
"existing_prs": []}
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: claim_issue.py <issue_number> [repo] [assignee]")
|
||||
print("Example: claim_issue.py 1492")
|
||||
print(" claim_issue.py 1339 Timmy_Foundation/the-nexus allegro")
|
||||
sys.exit(2)
|
||||
|
||||
issue_num = int(sys.argv[1])
|
||||
repo = sys.argv[2] if len(sys.argv) > 2 else "Timmy_Foundation/the-nexus"
|
||||
assignee = sys.argv[3] if len(sys.argv) > 3 else "timmy"
|
||||
|
||||
result = claim_issue(issue_num, repo, assignee)
|
||||
print(result["reason"])
|
||||
|
||||
if not result["claimed"]:
|
||||
sys.exit(1)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user