| """ |
| Code Review Schema |
| |
| GitHub PR review-style annotation with inline diff commenting, |
| file-level ratings, and overall verdict. |
| |
| Features: |
| - Click on diff lines in CodingTraceDisplay to add comments |
| - Per-comment category (bug, style, suggestion, security, question) |
| - File-level correctness and quality ratings |
| - Overall verdict (approve, request_changes, comment_only) |
| """ |
|
|
| import json |
| import logging |
| from typing import Dict, Any, Tuple, List |
|
|
| from .identifier_utils import ( |
| safe_generate_layout, |
| generate_element_identifier, |
| generate_validation_attribute, |
| escape_html_content, |
| generate_layout_attributes, |
| ) |
|
|
| logger = logging.getLogger(__name__) |
|
|
| DEFAULT_CATEGORIES = ["bug", "style", "suggestion", "security", "question"] |
| DEFAULT_VERDICTS = ["approve", "request_changes", "comment_only"] |
| DEFAULT_RATING_DIMS = ["correctness", "quality"] |
|
|
|
|
| def generate_code_review_layout( |
| annotation_scheme: Dict[str, Any], |
| ) -> Tuple[str, List[Tuple[str, str]]]: |
| """Generate HTML for a code review annotation interface. |
| |
| Args: |
| annotation_scheme: Configuration dict. Required keys: ``name``, |
| ``description``. Optional: ``comment_categories``, |
| ``verdict_options``, ``file_rating_dimensions``. |
| |
| Returns: |
| ``(html, keybindings)`` tuple. |
| """ |
| return safe_generate_layout(annotation_scheme, _generate_internal) |
|
|
|
|
| def _generate_internal( |
| annotation_scheme: Dict[str, Any], |
| ) -> Tuple[str, List[Tuple[str, str]]]: |
| schema_name = annotation_scheme["name"] |
| description = annotation_scheme["description"] |
|
|
| categories = annotation_scheme.get("comment_categories", DEFAULT_CATEGORIES) |
| verdicts = annotation_scheme.get("verdict_options", DEFAULT_VERDICTS) |
| rating_dims = annotation_scheme.get("file_rating_dimensions", DEFAULT_RATING_DIMS) |
|
|
| layout_attrs = generate_layout_attributes(annotation_scheme) |
| validation = generate_validation_attribute(annotation_scheme) |
| identifiers = generate_element_identifier(schema_name, schema_name, "hidden") |
| esc_schema = escape_html_content(schema_name) |
|
|
| config_json = json.dumps({ |
| "categories": categories, |
| "verdicts": verdicts, |
| "rating_dims": rating_dims, |
| }) |
|
|
| |
| verdict_html = "" |
| for v in verdicts: |
| label = v.replace("_", " ").title() |
| css = f"cr-verdict-{v}" |
| verdict_html += ( |
| f'<label class="cr-verdict-option {css}">' |
| f'<input type="radio" name="cr-verdict-{esc_schema}" value="{escape_html_content(v)}">' |
| f' {escape_html_content(label)}' |
| f'</label>' |
| ) |
|
|
| |
| cat_options = "".join( |
| f'<option value="{escape_html_content(c)}">{escape_html_content(c.title())}</option>' |
| for c in categories |
| ) |
|
|
| html = f""" |
| <form id="{esc_schema}" class="annotation-form code-review-container" |
| action="javascript:void(0)" |
| data-annotation-id="{escape_html_content(str(annotation_scheme.get('annotation_id', '')))}" |
| data-annotation-type="code_review" |
| data-schema-name="{esc_schema}" |
| {layout_attrs}> |
| <fieldset schema="{esc_schema}"> |
| <legend class="cr-title">{escape_html_content(description)}</legend> |
| |
| <!-- Verdict section --> |
| <div class="cr-section"> |
| <div class="cr-section-label">Verdict</div> |
| <div class="cr-verdict-group" id="{esc_schema}-verdicts"> |
| {verdict_html} |
| </div> |
| </div> |
| |
| <!-- Comments section --> |
| <div class="cr-section"> |
| <div class="cr-section-label"> |
| Comments |
| <button type="button" class="cr-add-comment-btn" id="{esc_schema}-add-comment">+ Add Comment</button> |
| </div> |
| <div class="cr-comments-list" id="{esc_schema}-comments"></div> |
| </div> |
| |
| <!-- Comment template (hidden, cloned by JS) --> |
| <template id="{esc_schema}-comment-template"> |
| <div class="cr-comment-card"> |
| <div class="cr-comment-header"> |
| <select class="cr-comment-category"> |
| {cat_options} |
| </select> |
| <input type="text" class="cr-comment-file" placeholder="File path (optional)"> |
| <input type="number" class="cr-comment-line" placeholder="Line" min="1" style="width:60px"> |
| <button type="button" class="cr-comment-delete" title="Remove">×</button> |
| </div> |
| <textarea class="cr-comment-text" rows="2" placeholder="Describe the issue..."></textarea> |
| </div> |
| </template> |
| |
| <!-- File ratings section --> |
| <div class="cr-section" id="{esc_schema}-ratings-section" style="display:none"> |
| <div class="cr-section-label">File Ratings</div> |
| <div class="cr-ratings-list" id="{esc_schema}-ratings"></div> |
| </div> |
| |
| <input type="hidden" |
| class="annotation-input code-review-data-input" |
| id="{identifiers['id']}" |
| name="{identifiers['name']}" |
| schema="{identifiers['schema']}" |
| label_name="{identifiers['label_name']}" |
| validation="{validation}" |
| value=""> |
| </fieldset> |
| </form> |
| |
| <script> |
| (function() {{ |
| var SCHEMA = '{esc_schema}'; |
| var CONFIG = {config_json}; |
| var _state = {{ verdict: '', comments: [], file_ratings: {{}} }}; |
| |
| function init() {{ |
| // Check for existing value (persistence) |
| var input = document.getElementById(SCHEMA).querySelector('.code-review-data-input'); |
| if (input && input.value) {{ |
| try {{ |
| _state = JSON.parse(input.value); |
| if (!_state.comments) _state.comments = []; |
| if (!_state.file_ratings) _state.file_ratings = {{}}; |
| }} catch(e) {{}} |
| }} |
| |
| restoreState(); |
| attachHandlers(); |
| detectFiles(); |
| }} |
| |
| function restoreState() {{ |
| // Restore verdict |
| if (_state.verdict) {{ |
| var radio = document.querySelector( |
| '#' + SCHEMA + '-verdicts input[value="' + _state.verdict + '"]' |
| ); |
| if (radio) radio.checked = true; |
| }} |
| |
| // Restore comments |
| var commentsContainer = document.getElementById(SCHEMA + '-comments'); |
| if (commentsContainer) {{ |
| commentsContainer.innerHTML = ''; |
| _state.comments.forEach(function(c) {{ |
| addCommentCard(c); |
| }}); |
| }} |
| }} |
| |
| function attachHandlers() {{ |
| // Verdict radios (with :has() fallback via .cr-checked class) |
| document.querySelectorAll('#' + SCHEMA + '-verdicts input[type="radio"]').forEach(function(r) {{ |
| r.addEventListener('change', function() {{ |
| _state.verdict = r.value; |
| // Fallback for browsers without :has() support |
| document.querySelectorAll('#' + SCHEMA + '-verdicts .cr-verdict-option').forEach(function(opt) {{ |
| opt.classList.toggle('cr-checked', opt.querySelector('input:checked') !== null); |
| }}); |
| saveState(); |
| }}); |
| }}); |
| |
| // Add comment button |
| var addBtn = document.getElementById(SCHEMA + '-add-comment'); |
| if (addBtn) {{ |
| addBtn.addEventListener('click', function() {{ |
| var comment = {{ category: CONFIG.categories[0], file: '', line: null, text: '' }}; |
| _state.comments.push(comment); |
| addCommentCard(comment); |
| saveState(); |
| }}); |
| }} |
| |
| // Listen for diff line clicks from CodingTraceDisplay |
| document.addEventListener('click', function(e) {{ |
| var line = e.target.closest('.ct-diff-line, .ct-code-line'); |
| if (!line) return; |
| var toolCall = line.closest('.ct-tool-call'); |
| if (!toolCall) return; |
| var fileEl = toolCall.querySelector('.ct-file-path'); |
| var file = fileEl ? fileEl.textContent : ''; |
| var lineNum = ''; |
| var numEl = line.querySelector('.ct-line-num'); |
| if (numEl) lineNum = numEl.textContent; |
| |
| var comment = {{ category: CONFIG.categories[0], file: file, line: lineNum ? parseInt(lineNum) : null, text: '' }}; |
| _state.comments.push(comment); |
| addCommentCard(comment); |
| saveState(); |
| |
| // Focus the new comment text |
| var cards = document.getElementById(SCHEMA + '-comments').querySelectorAll('.cr-comment-card'); |
| var lastCard = cards[cards.length - 1]; |
| if (lastCard) {{ |
| var textarea = lastCard.querySelector('.cr-comment-text'); |
| if (textarea) textarea.focus(); |
| }} |
| }}); |
| }} |
| |
| function addCommentCard(commentData) {{ |
| var template = document.getElementById(SCHEMA + '-comment-template'); |
| var commentsContainer = document.getElementById(SCHEMA + '-comments'); |
| if (!template || !commentsContainer) return; |
| |
| var clone = template.content.cloneNode(true); |
| var card = clone.querySelector('.cr-comment-card'); |
| var idx = commentsContainer.querySelectorAll('.cr-comment-card').length; |
| card.setAttribute('data-comment-index', idx); |
| |
| // Set values |
| var catSel = card.querySelector('.cr-comment-category'); |
| if (catSel && commentData.category) catSel.value = commentData.category; |
| |
| var fileInput = card.querySelector('.cr-comment-file'); |
| if (fileInput && commentData.file) fileInput.value = commentData.file; |
| |
| var lineInput = card.querySelector('.cr-comment-line'); |
| if (lineInput && commentData.line) lineInput.value = commentData.line; |
| |
| var textArea = card.querySelector('.cr-comment-text'); |
| if (textArea && commentData.text) textArea.value = commentData.text; |
| |
| // Attach handlers |
| catSel.addEventListener('change', function() {{ |
| var i = getCardIndex(card); |
| if (_state.comments[i]) _state.comments[i].category = catSel.value; |
| saveState(); |
| }}); |
| fileInput.addEventListener('input', function() {{ |
| var i = getCardIndex(card); |
| if (_state.comments[i]) _state.comments[i].file = fileInput.value; |
| saveState(); |
| }}); |
| lineInput.addEventListener('input', function() {{ |
| var i = getCardIndex(card); |
| if (_state.comments[i]) _state.comments[i].line = lineInput.value ? parseInt(lineInput.value) : null; |
| saveState(); |
| }}); |
| textArea.addEventListener('input', function() {{ |
| var i = getCardIndex(card); |
| if (_state.comments[i]) _state.comments[i].text = textArea.value; |
| saveState(); |
| }}); |
| |
| var deleteBtn = card.querySelector('.cr-comment-delete'); |
| deleteBtn.addEventListener('click', function() {{ |
| var i = getCardIndex(card); |
| _state.comments.splice(i, 1); |
| card.remove(); |
| reindexCards(); |
| saveState(); |
| }}); |
| |
| commentsContainer.appendChild(clone); |
| }} |
| |
| function getCardIndex(card) {{ |
| return parseInt(card.getAttribute('data-comment-index'), 10); |
| }} |
| |
| function reindexCards() {{ |
| var container = document.getElementById(SCHEMA + '-comments'); |
| if (!container) return; |
| container.querySelectorAll('.cr-comment-card').forEach(function(card, i) {{ |
| card.setAttribute('data-comment-index', i); |
| }}); |
| }} |
| |
| function detectFiles() {{ |
| // Auto-detect files from CodingTraceDisplay |
| var files = new Set(); |
| document.querySelectorAll('.ct-file-path').forEach(function(el) {{ |
| if (el.textContent) files.add(el.textContent.trim()); |
| }}); |
| if (files.size === 0) return; |
| |
| var ratingsSection = document.getElementById(SCHEMA + '-ratings-section'); |
| var ratingsContainer = document.getElementById(SCHEMA + '-ratings'); |
| if (!ratingsSection || !ratingsContainer) return; |
| |
| ratingsSection.style.display = 'block'; |
| ratingsContainer.innerHTML = ''; |
| |
| files.forEach(function(file) {{ |
| var row = document.createElement('div'); |
| row.className = 'cr-rating-row'; |
| var dims = ''; |
| CONFIG.rating_dims.forEach(function(dim) {{ |
| var existing = (_state.file_ratings[file] || {{}})[dim] || 0; |
| dims += '<div class="cr-rating-dim">' + |
| '<span class="cr-dim-label">' + dim + ':</span>'; |
| for (var i = 1; i <= 5; i++) {{ |
| var sel = (existing === i) ? ' selected' : ''; |
| dims += '<button type="button" class="cr-star' + sel + '" data-file="' + |
| escapeAttr(file) + '" data-dim="' + dim + '" data-val="' + i + '">' + i + '</button>'; |
| }} |
| dims += '</div>'; |
| }}); |
| row.innerHTML = '<div class="cr-rating-file">' + escapeHtml(file) + '</div>' + dims; |
| ratingsContainer.appendChild(row); |
| }}); |
| |
| ratingsContainer.querySelectorAll('.cr-star').forEach(function(btn) {{ |
| btn.addEventListener('click', function() {{ |
| var file = btn.getAttribute('data-file'); |
| var dim = btn.getAttribute('data-dim'); |
| var val = parseInt(btn.getAttribute('data-val'), 10); |
| if (!_state.file_ratings[file]) _state.file_ratings[file] = {{}}; |
| _state.file_ratings[file][dim] = val; |
| |
| // Update visual |
| var siblings = ratingsContainer.querySelectorAll( |
| '.cr-star[data-file="' + escapeAttr(file) + '"][data-dim="' + dim + '"]' |
| ); |
| siblings.forEach(function(s) {{ |
| var sv = parseInt(s.getAttribute('data-val'), 10); |
| if (sv <= val) s.classList.add('selected'); |
| else s.classList.remove('selected'); |
| }}); |
| saveState(); |
| }}); |
| }}); |
| }} |
| |
| function saveState() {{ |
| var data = JSON.stringify(_state); |
| var input = document.getElementById(SCHEMA).querySelector('.code-review-data-input'); |
| if (input) {{ |
| input.value = data; |
| input.setAttribute('data-modified', 'true'); |
| input.dispatchEvent(new Event('change', {{ bubbles: true }})); |
| }} |
| }} |
| |
| function escapeHtml(t) {{ var d = document.createElement('div'); d.textContent = t; return d.innerHTML; }} |
| function escapeAttr(t) {{ return t.replace(/"/g, '"').replace(/'/g, '''); }} |
| |
| if (document.readyState === 'loading') {{ |
| document.addEventListener('DOMContentLoaded', init); |
| }} else {{ |
| init(); |
| }} |
| document.addEventListener('instanceChanged', init); |
| }})(); |
| </script> |
| |
| <style> |
| .code-review-container {{ font-family: inherit; }} |
| .cr-title {{ font-weight: 600; font-size: 1em; margin-bottom: 4px; }} |
| .cr-section {{ margin-bottom: 12px; }} |
| .cr-section-label {{ |
| font-size: 0.85em; font-weight: 600; color: var(--muted-foreground, #71717a); |
| margin-bottom: 6px; display: flex; align-items: center; gap: 8px; |
| }} |
| .cr-verdict-group {{ display: flex; gap: 8px; flex-wrap: wrap; }} |
| .cr-verdict-option {{ |
| padding: 6px 12px; border: 1px solid var(--border, #e4e4e7); |
| border-radius: var(--radius, 0.5rem); |
| cursor: pointer; font-size: 0.9em; transition: all 0.15s; |
| }} |
| .cr-verdict-option:hover {{ border-color: #999; }} |
| .cr-verdict-option input {{ margin-right: 4px; }} |
| /* :has() with JS fallback class .cr-checked */ |
| .cr-verdict-option:has(input:checked), |
| .cr-verdict-option.cr-checked {{ |
| border-color: var(--primary, #6e56cf); background: color-mix(in srgb, var(--primary, #6e56cf) 8%, transparent); |
| }} |
| .cr-verdict-approve:has(input:checked), |
| .cr-verdict-approve.cr-checked {{ |
| border-color: #388e3c; background: #e8f5e9; |
| }} |
| .cr-verdict-request_changes:has(input:checked), |
| .cr-verdict-request_changes.cr-checked {{ |
| border-color: #d32f2f; background: #ffebee; |
| }} |
| .cr-verdict-option:focus-within {{ outline: 2px solid var(--ring, #6e56cf); outline-offset: 2px; }} |
| .cr-add-comment-btn {{ |
| padding: 2px 8px; font-size: 0.85em; border: 1px solid var(--border, #e4e4e7); |
| border-radius: 4px; background: var(--card, #fff); cursor: pointer; |
| color: var(--primary, #6e56cf); |
| }} |
| .cr-add-comment-btn:hover {{ background: var(--secondary, #f4f4f5); }} |
| .cr-add-comment-btn:focus-visible {{ outline: 2px solid var(--ring, #6e56cf); outline-offset: 2px; }} |
| .cr-comments-list {{ display: flex; flex-direction: column; gap: 6px; }} |
| .cr-comment-card {{ |
| border: 1px solid var(--border, #e4e4e7); border-radius: var(--radius, 0.5rem); padding: 8px; |
| background: var(--card, #fff); |
| }} |
| .cr-comment-header {{ |
| display: flex; gap: 6px; align-items: center; margin-bottom: 4px; |
| }} |
| .cr-comment-category {{ |
| padding: 2px 6px; border: 1px solid var(--border, #e4e4e7); border-radius: 4px; |
| font-size: 0.85em; |
| }} |
| .cr-comment-file {{ |
| flex: 1; padding: 2px 6px; border: 1px solid var(--border, #e4e4e7); border-radius: 4px; |
| font-size: 0.85em; font-family: var(--font-mono, monospace); |
| }} |
| .cr-comment-line {{ |
| padding: 2px 6px; border: 1px solid var(--border, #e4e4e7); border-radius: 4px; |
| font-size: 0.85em; |
| }} |
| .cr-comment-delete {{ |
| background: none; border: none; cursor: pointer; color: var(--muted-foreground, #71717a); |
| font-size: 1.2em; padding: 0 4px; line-height: 1; |
| }} |
| .cr-comment-delete:hover {{ color: var(--destructive, #ef4444); }} |
| .cr-comment-text {{ |
| width: 100%; padding: 4px 6px; border: 1px solid var(--border, #e4e4e7); |
| border-radius: 4px; font-size: 0.85em; resize: vertical; |
| box-sizing: border-box; |
| }} |
| .cr-comment-text:focus, .cr-comment-file:focus, .cr-comment-line:focus, .cr-comment-category:focus {{ |
| outline: 2px solid var(--ring, #6e56cf); outline-offset: -1px; |
| }} |
| .cr-rating-row {{ |
| padding: 6px 0; border-bottom: 1px solid var(--secondary, #f4f4f5); |
| }} |
| .cr-rating-file {{ |
| font-family: var(--font-mono, monospace); font-size: 0.85em; |
| color: var(--muted-foreground, #71717a); margin-bottom: 4px; |
| }} |
| .cr-rating-dim {{ |
| display: inline-flex; align-items: center; gap: 2px; margin-right: 12px; |
| }} |
| .cr-dim-label {{ font-size: 0.8em; color: var(--muted-foreground, #71717a); margin-right: 4px; }} |
| .cr-star {{ |
| width: 24px; height: 24px; border: 1px solid var(--border, #e4e4e7); border-radius: 4px; |
| background: var(--card, #fff); cursor: pointer; font-size: 0.8em; padding: 0; |
| transition: all 0.1s; |
| }} |
| .cr-star:hover {{ border-color: #ffa726; }} |
| .cr-star:focus-visible {{ outline: 2px solid var(--ring, #6e56cf); outline-offset: 2px; }} |
| .cr-star.selected {{ background: #ffa726; color: #fff; border-color: #ffa726; }} |
| </style> |
| """ |
|
|
| logger.info( |
| f"Successfully generated code_review layout for {schema_name} " |
| f"({len(categories)} categories, {len(verdicts)} verdicts)" |
| ) |
| return html, [] |
|
|