Skip to content

Commit 859bc4f

Browse files
authored
Merge pull request #53 from HexmosTech/enhancement/auto-fix-claude
Implement Claude Code handoff
2 parents 6a6d76b + faef47f commit 859bc4f

8 files changed

Lines changed: 238 additions & 29 deletions

File tree

internal/appcore/interactive_decision.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ package appcore
33
import (
44
"fmt"
55
"os"
6+
"os/exec"
7+
"path/filepath"
68
"strings"
79

810
"github.com/HexmosTech/git-lrc/internal/decisionflow"
11+
"github.com/HexmosTech/git-lrc/internal/reviewapi"
912
"github.com/urfave/cli/v2"
1013
)
1114

@@ -98,6 +101,38 @@ func executeDecision(code int, message string, push bool, ctx decisionExecutionC
98101
return err
99102
}
100103
return nil
104+
case decisionflow.DecisionHandoff:
105+
syncedPrintln("\n🤖 Handing off to Claude Code...")
106+
107+
gitDir, err := reviewapi.ResolveGitDir()
108+
if err != nil {
109+
return fmt.Errorf("failed to resolve git directory: %w", err)
110+
}
111+
112+
reviewDir := filepath.Join(gitDir, "lrc", "reviews", ctx.reviewID)
113+
if err := os.MkdirAll(reviewDir, 0755); err != nil {
114+
return fmt.Errorf("failed to create review directory: %w", err)
115+
}
116+
117+
jsonPath := filepath.Join(reviewDir, "review_findings.json")
118+
if err := os.WriteFile(jsonPath, []byte(message), 0644); err != nil {
119+
return fmt.Errorf("failed to write review findings: %w", err)
120+
}
121+
122+
promptMsg := fmt.Sprintf(ClaudeHandoffPromptTemplate, jsonPath)
123+
cmdArgs := []string{promptMsg}
124+
syncedPrintln("🚀 Running: claude code")
125+
126+
cmd := exec.Command("claude", cmdArgs...)
127+
cmd.Stdin = os.Stdin
128+
cmd.Stdout = os.Stdout
129+
cmd.Stderr = os.Stderr
130+
131+
if err := cmd.Run(); err != nil {
132+
return fmt.Errorf("claude agent failed: %w", err)
133+
}
134+
135+
return cli.Exit("", 0) // exit cleanly after fix
101136
default:
102137
return fmt.Errorf("invalid decision code: %d", code)
103138
}

internal/appcore/prompt.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package appcore
2+
3+
const ClaudeHandoffPromptTemplate = `Read %s. This JSON contains code review feedback on my recent changes. The 'hunks' show the current buggy code, and 'comments' explain the issues. Please modify the source files in this workspace to fix the errors mentioned in the comments. Do not treat the 'hunks' as the solution. Briefly explain what you fixed, and then politely remind me that I can type /exit to close this session.`

internal/appcore/review_runtime.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,18 @@ func runReviewWithOptions(opts reviewopts.Options) error {
654654
}
655655
handleProgressiveDecision(w, decisionflow.DecisionAbort, "", false)
656656
})
657+
mux.HandleFunc("/handoff", func(w http.ResponseWriter, r *http.Request) {
658+
if r.Method != http.MethodPost {
659+
w.WriteHeader(http.StatusMethodNotAllowed)
660+
return
661+
}
662+
body, err := io.ReadAll(r.Body)
663+
if err != nil {
664+
http.Error(w, "Failed to read request body", http.StatusBadRequest)
665+
return
666+
}
667+
handleProgressiveDecision(w, decisionflow.DecisionHandoff, string(body), false)
668+
})
657669
// Proxy endpoint for review-events API to avoid CORS
658670
mux.HandleFunc("/api/v1/diff-review/", func(w http.ResponseWriter, r *http.Request) {
659671
if fakeMode {
@@ -2665,6 +2677,19 @@ func serveHTMLInteractive(htmlPath string, port int, ln net.Listener, initialMsg
26652677
handleDecision(w, decisionflow.DecisionAbort, "", false)
26662678
})
26672679

2680+
mux.HandleFunc("/handoff", func(w http.ResponseWriter, r *http.Request) {
2681+
if r.Method != http.MethodPost {
2682+
w.WriteHeader(http.StatusMethodNotAllowed)
2683+
return
2684+
}
2685+
body, err := io.ReadAll(r.Body)
2686+
if err != nil {
2687+
http.Error(w, "Failed to read request body", http.StatusBadRequest)
2688+
return
2689+
}
2690+
handleDecision(w, decisionflow.DecisionHandoff, string(body), false)
2691+
})
2692+
26682693
// Start server in background using the already-open listener
26692694
server := &http.Server{
26702695
Handler: mux,

internal/decisionflow/decisionflow.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ import (
66
)
77

88
const (
9-
DecisionCommit = 0 // proceed with commit
10-
DecisionAbort = 1 // abort commit
11-
DecisionSkip = 2 // skip review, proceed with commit
12-
DecisionVouch = 4 // vouch for changes, proceed with commit
9+
DecisionCommit = 0 // proceed with commit
10+
DecisionAbort = 1 // abort commit
11+
DecisionSkip = 2 // skip review, proceed with commit
12+
DecisionVouch = 4 // vouch for changes, proceed with commit
13+
DecisionHandoff = 5 // handoff to AI agent
1314
)
1415

1516
type Phase int
@@ -25,7 +26,7 @@ func ActionAllowedInPhase(code int, phase Phase) bool {
2526
return true
2627
case DecisionSkip, DecisionVouch:
2728
return phase == PhaseReviewRunning
28-
case DecisionCommit:
29+
case DecisionCommit, DecisionHandoff:
2930
return phase == PhaseReviewComplete
3031
default:
3132
return false

internal/staticserve/static/app.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ async function initApp() {
197197
const [isTailing, setIsTailing] = useState(false);
198198
const [hiddenCommentKeys, setHiddenCommentKeys] = useState(new Set());
199199
const [copyFeedback, setCopyFeedback] = useState({ status: 'idle', message: '' });
200+
const [handoffModal, setHandoffModal] = useState({ isOpen: false, type: '', message: '' });
200201

201202
const pollingRef = useRef(null);
202203
const eventsPollingRef = useRef(null);
@@ -442,6 +443,56 @@ async function initApp() {
442443
});
443444
}, []);
444445

446+
const handleSendToAgent = useCallback(async () => {
447+
const filteredFiles = (reviewData.files || reviewData.Files || []).map(file => {
448+
const filePath = file.file_path || file.filePath || file.FilePath;
449+
const newComments = (file.comments || file.Comments || []).filter(c => {
450+
const sev = (c.severity || c.Severity || '').toLowerCase();
451+
if (!visibleSeverities.has(sev)) return false;
452+
const key = getCommentVisibilityKey(filePath, c);
453+
return !hiddenCommentKeys.has(key);
454+
});
455+
return { ...file, comments: newComments, Comments: newComments };
456+
}).filter(file => file.comments.length > 0);
457+
458+
if (filteredFiles.length === 0) {
459+
setHandoffModal({
460+
isOpen: true,
461+
type: 'error',
462+
message: "No visible comments to send to the AI agent. Please show some comments first."
463+
});
464+
return;
465+
}
466+
467+
const payload = {
468+
...reviewData,
469+
files: filteredFiles,
470+
Files: filteredFiles,
471+
summary: "AI Agent Handoff generated for visible issues.",
472+
status: "completed"
473+
};
474+
475+
try {
476+
const response = await fetch('/handoff', {
477+
method: 'POST',
478+
headers: { 'Content-Type': 'application/json' },
479+
body: JSON.stringify(payload)
480+
});
481+
if (!response.ok) throw new Error("Handoff failed");
482+
setHandoffModal({
483+
isOpen: true,
484+
type: 'success',
485+
message: "Claude Code is now starting in your terminal! You can safely close this browser window."
486+
});
487+
} catch (e) {
488+
setHandoffModal({
489+
isOpen: true,
490+
type: 'error',
491+
message: "Failed to send to agent: " + e.message
492+
});
493+
}
494+
}, [reviewData, visibleSeverities, hiddenCommentKeys]);
495+
445496
const showCopyFeedback = useCallback((status, message) => {
446497
setCopyFeedback({ status, message });
447498
if (copyFeedbackTimerRef.current) {
@@ -614,6 +665,25 @@ async function initApp() {
614665
// Calculate totalComments from actual files - single source of truth
615666
const totalComments = files.reduce((sum, file) => sum + (file.CommentCount || 0), 0);
616667

668+
// Calculate visible comments for the agent button
669+
let totalVisibleComments = 0;
670+
files.forEach(file => {
671+
if (!file.HasComments) return;
672+
file.Hunks.forEach(hunk => {
673+
hunk.Lines.forEach(line => {
674+
if (line.IsComment && line.Comments) {
675+
line.Comments.forEach((comment) => {
676+
const sev = (comment.Severity || '').toLowerCase();
677+
if (!visibleSeverities.has(sev)) return;
678+
const visibilityKey = getCommentVisibilityKey(file.FilePath, comment);
679+
if (visibilityKey && hiddenCommentKeys.has(visibilityKey)) return;
680+
totalVisibleComments++;
681+
});
682+
}
683+
});
684+
});
685+
});
686+
617687
// Status display
618688
const getStatusDisplay = () => {
619689
if (reviewData?.blocked) {
@@ -708,6 +778,8 @@ async function initApp() {
708778
hiddenCommentKeys=${hiddenCommentKeys}
709779
copyFeedbackStatus=${copyFeedback.status}
710780
copyFeedbackMessage=${copyFeedback.message}
781+
onSendToAgent=${handleSendToAgent}
782+
visibleCount=${totalVisibleComments}
711783
/>
712784
`}
713785
@@ -735,6 +807,38 @@ async function initApp() {
735807
}
736808
</div>
737809
810+
${handoffModal.isOpen && html`
811+
<div class="modal-overlay" style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 9999; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px);">
812+
<div class="modal-content" style="background: var(--bg-card); padding: 32px; border-radius: 12px; max-width: 400px; width: 90%; border: 1px solid var(--border-color); box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5); text-align: center;">
813+
${handoffModal.type === 'success'
814+
? html`
815+
<div style="margin-bottom: 16px; color: #8b5cf6;">
816+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
817+
<rect x="2" y="3" width="20" height="18" rx="2" ry="2"></rect>
818+
<polyline points="6 8 10 12 6 16"></polyline>
819+
<line x1="14" y1="16" x2="18" y2="16"></line>
820+
</svg>
821+
</div>
822+
`
823+
: html`<div style="font-size: 48px; margin-bottom: 16px;">⚠️</div>`
824+
}
825+
<h3 style="margin: 0 0 12px 0; font-size: 20px; color: var(--text-primary);">
826+
${handoffModal.type === 'success' ? 'Check Your Terminal' : 'Notice'}
827+
</h3>
828+
<p style="margin: 0 0 24px 0; color: var(--text-secondary); line-height: 1.5;">
829+
${handoffModal.message}
830+
</p>
831+
<button
832+
class="btn btn-primary"
833+
onClick=${() => setHandoffModal({ ...handoffModal, isOpen: false })}
834+
style="width: 100%; padding: 12px; font-size: 16px;"
835+
>
836+
${handoffModal.type === 'success' ? 'Got it' : 'Close'}
837+
</button>
838+
</div>
839+
</div>
840+
`}
841+
738842
<!-- Events Tab -->
739843
<div id="events-tab" class="tab-content ${activeTab === 'events' ? 'active' : ''}" style="display: ${activeTab === 'events' ? 'block' : 'none'}">
740844
<${EventLog}

internal/staticserve/static/components/Comment.js

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export async function createComment() {
66

77
return function Comment({ comment, filePath, codeExcerpt, commentId, visibilityKey, isHidden, onToggleVisibility }) {
88
const [copied, setCopied] = useState(false);
9-
9+
1010
const handleCopy = async (e) => {
1111
e.stopPropagation();
1212

@@ -52,24 +52,23 @@ export async function createComment() {
5252
<tr class="comment-row ${isHidden ? 'comment-row-hidden' : ''}" data-line="${comment.Line}" id="${commentId}">
5353
<td colspan="3">
5454
<div class="comment-visibility-row">
55-
<button
56-
type="button"
57-
class="comment-visibility-toggle ${isHidden ? 'off' : 'on'}"
58-
title="${isHidden ? 'Show comment' : 'Hide comment'}"
59-
aria-pressed="${!isHidden}"
60-
onClick=${handleToggleVisibility}
61-
>
62-
${isHidden
63-
? html`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-5 0-9.27-3.11-11-7.5a11.8 11.8 0 012.89-4.11M9.88 9.88a3 3 0 104.24 4.24"/><path d="M1 1l22 22"/></svg>`
64-
: html`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>`
65-
}
66-
</button>
6755
${isHidden
6856
? html`
69-
<div class="comment-hidden-placeholder">
57+
<div class="comment-hidden-placeholder" style="position: relative;">
58+
<div class="comment-actions" style="display: flex; gap: 8px; position: absolute; right: 12px; top: 12px;">
59+
<button
60+
class="comment-visibility-btn"
61+
title="Show this comment to the AI Agent"
62+
onClick=${handleToggleVisibility}
63+
style="position: static; opacity: 1;"
64+
>
65+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>
66+
Show
67+
</button>
68+
</div>
7069
<span class="comment-hidden-title">Comment hidden</span>
7170
<span class="comment-hidden-meta">${filePath}${lineLabel}</span>
72-
<span class="comment-hidden-note">Hidden comments are excluded from Copy Visible Issues.</span>
71+
<span class="comment-hidden-note">Hidden comments are excluded from Copy Visible Issues and the Claude Agent.</span>
7372
</div>
7473
`
7574
: html`
@@ -79,13 +78,25 @@ export async function createComment() {
7978
data-line="${comment.Line}"
8079
data-comment="${comment.Content}"
8180
>
82-
<button
83-
class="comment-copy-btn ${copied ? 'copied' : ''}"
84-
title="Copy issue details"
85-
onClick=${handleCopy}
86-
>
87-
${copied ? 'Copied!' : 'Copy'}
88-
</button>
81+
<div class="comment-actions" style="display: flex; gap: 8px; position: absolute; right: 12px; top: 12px;">
82+
<button
83+
class="comment-visibility-btn"
84+
title="Hide this comment from the AI Agent"
85+
onClick=${handleToggleVisibility}
86+
style="position: static; opacity: 1;"
87+
>
88+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-5 0-9.27-3.11-11-7.5a11.8 11.8 0 012.89-4.11M9.88 9.88a3 3 0 104.24 4.24"/><path d="M1 1l22 22"/></svg>
89+
Hide
90+
</button>
91+
<button
92+
class="comment-copy-btn ${copied ? 'copied' : ''}"
93+
title="Copy issue details"
94+
onClick=${handleCopy}
95+
style="position: static;"
96+
>
97+
${copied ? 'Copied!' : 'Copy'}
98+
</button>
99+
</div>
89100
<div class="comment-header">
90101
<span class="comment-badge ${badgeClass}">${comment.Severity}</span>
91102
${comment.HasCategory && html`

internal/staticserve/static/components/SeverityFilter.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ export async function createSeverityFilter() {
1111
onCopyVisibleIssues,
1212
hiddenCommentKeys,
1313
copyFeedbackStatus,
14-
copyFeedbackMessage
14+
copyFeedbackMessage,
15+
onSendToAgent,
16+
visibleCount
1517
}) {
1618
const counts = countIssuesBySeverity(files, visibleSeverities, hiddenCommentKeys);
1719
if (counts.total === 0) return null;
@@ -66,7 +68,7 @@ export async function createSeverityFilter() {
6668
</button>
6769
</div>
6870
<span class="severity-filter-summary">${filterLabel}</span>
69-
<div class="copy-visible-wrapper">
71+
<div class="copy-visible-wrapper" style="flex-direction: row; align-items: center; gap: 8px;">
7072
<button
7173
class="btn btn-primary copy-visible-btn ${buttonState}"
7274
onClick=${onCopyVisibleIssues}
@@ -77,6 +79,13 @@ export async function createSeverityFilter() {
7779
</svg>
7880
${buttonLabel}
7981
</button>
82+
<button
83+
class="btn btn-primary"
84+
onClick=${onSendToAgent}
85+
title="Send visible issues to Claude"
86+
>
87+
Send to Claude (${visibleCount})
88+
</button>
8089
${copyFeedbackMessage && html`
8190
<div class="copy-feedback copy-feedback-${copyFeedbackStatus}" role="status" aria-live="polite">
8291
${copyFeedbackMessage}

0 commit comments

Comments
 (0)