Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ Strategic line-specific PR commenting for GitHub CLI (optimized for AI)

`gh-comment` is the first GitHub CLI extension designed for comprehensive PR comment management. It provides a unified system for both general PR discussion and line-specific code review comments, filling a genuine gap in the GitHub CLI ecosystem. Features smart suggestion expansion, complete comment visibility, and universal reply capabilities. Built specifically for AI assistants and automated workflows.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Great documentation structure

**NEW**: Now includes enhanced line-by-line commenting with improved validation!
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Testing comment without validation

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Validation fix confirmed working

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This enhancement makes review body optional - great improvement!

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Test validation works with fixed diff parsing


Copy link
Copy Markdown
Owner Author

@silouanwright silouanwright Aug 5, 2025

Choose a reason for hiding this comment

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

Test batch comment from integration testing - EDITED

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Production integration test - line-specific review comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Production test: Batch review comment #1

## Features

- 🤖 **AI-optimized design**: Specifically built for usage with AI assistants and automated workflows
Expand Down
37 changes: 20 additions & 17 deletions cmd/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@ const (
MaxBranchLength = 255 // Git branch name max length
DefaultPageSize = 30

// New constants for line comment validation
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Nice constant definition

MaxLineNumber = 50000 // Maximum line number for commenting
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Excellent addition of MaxLineNumber constant for validation

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Added MaxLineNumber constant successfully

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Production test: Batch review comment #2


// Security validation constants
MaxCommentThreadDepth = 10 // Maximum nested comment thread depth
MaxRepositoryDepth = 5 // Maximum repository path depth
MaxValidationErrors = 50 // Maximum validation errors to report
MaxCommentThreadDepth = 10 // Maximum nested comment thread depth
MaxRepositoryDepth = 5 // Maximum repository path depth
MaxValidationErrors = 50 // Maximum validation errors to report

// Display constants
MaxDisplayBodyLength = 200 // Max length for comment body display
Expand All @@ -35,10 +38,10 @@ const (
// Security validation patterns
var (
// HTML/Script tag detection patterns
htmlTagPattern = regexp.MustCompile(`(?i)<\s*\/?\s*(script|iframe|object|embed|form|input|meta|link)\b[^>]*>`)
scriptPattern = regexp.MustCompile(`(?i)javascript\s*:|<\s*script\b`)
htmlTagPattern = regexp.MustCompile(`(?i)<\s*\/?\s*(script|iframe|object|embed|form|input|meta|link)\b[^>]*>`)
scriptPattern = regexp.MustCompile(`(?i)javascript\s*:|<\s*script\b`)
dangerousAttrPattern = regexp.MustCompile(`(?i)\b(on\w+|javascript|vbscript|data|mocha|livescript)\s*=`)

// Repository validation patterns
validRepoPattern = regexp.MustCompile(`^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$`)
validOwnerPattern = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
Expand Down Expand Up @@ -173,7 +176,7 @@ func parsePositiveInt(s, fieldName string) (int, error) {
// validateCommentBody validates comment body length and content for security
func validateCommentBody(body string) error {
if len(body) > MaxCommentLength {
return formatValidationError("comment body", fmt.Sprintf("%d chars", len(body)),
return formatValidationError("comment body", fmt.Sprintf("%d chars", len(body)),
fmt.Sprintf("must be %d characters or less", MaxCommentLength))
}

Expand All @@ -189,19 +192,19 @@ func validateCommentBody(body string) error {
func validateCommentSecurity(body string) error {
// Check for dangerous HTML tags
if htmlTagPattern.MatchString(body) {
return formatSecurityValidationError("comment body", "dangerous HTML tags detected",
return formatSecurityValidationError("comment body", "dangerous HTML tags detected",
"HTML tags like <script>, <iframe>, <object>, <embed>, <form>, <input>, <meta>, <link> are not allowed")
}
Comment on lines +195 to 197
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Suggested change
return formatSecurityValidationError("comment body", "dangerous HTML tags detected",
"HTML tags like <script>, <iframe>, <object>, <embed>, <form>, <input>, <meta>, <link> are not allowed")
}
return formatSecurityValidationError("comment body", "dangerous HTML tags detected",
"HTML tags like <script>, <human>, <object>, <embed>, <form>, <input>, <meta>, <link> are not allowed")
}

making human line comment on non review comment


// Check for JavaScript and event handlers
if scriptPattern.MatchString(body) {
return formatSecurityValidationError("comment body", "JavaScript content detected",
return formatSecurityValidationError("comment body", "JavaScript content detected",
"JavaScript code, inline event handlers, and script tags are not allowed")
}

// Check for dangerous attributes
if dangerousAttrPattern.MatchString(body) {
return formatSecurityValidationError("comment body", "dangerous attributes detected",
return formatSecurityValidationError("comment body", "dangerous attributes detected",
"Event handlers and script attributes like onclick, onload, href='javascript:' are not allowed")
}

Expand Down Expand Up @@ -263,24 +266,24 @@ func validateRepositoryAccess(repo string) error {

// Check for potentially dangerous repository patterns
if strings.Contains(owner, "..") || strings.Contains(repoName, "..") {
return formatAccessValidationError("repository", "access repository with path traversal",
return formatAccessValidationError("repository", "access repository with path traversal",
"Repository names cannot contain '..' sequences for security reasons")
}

// Validate against pattern for additional security
if !validRepoPattern.MatchString(repo) {
return formatAccessValidationError("repository", "access repository with invalid characters",
return formatAccessValidationError("repository", "access repository with invalid characters",
"Repository must match pattern 'owner/repo' with only alphanumeric, dot, underscore, and hyphen characters")
}

// Check for reserved or potentially dangerous names
dangerousNames := []string{".", "..", "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"}
ownerUpper := strings.ToUpper(owner)
repoUpper := strings.ToUpper(repoName)

for _, dangerous := range dangerousNames {
if ownerUpper == dangerous || repoUpper == dangerous {
return formatAccessValidationError("repository", "access repository with reserved name",
return formatAccessValidationError("repository", "access repository with reserved name",
"Repository owner or name cannot use reserved system names")
}
}
Expand All @@ -293,9 +296,9 @@ func validateCommentThreadDepth(depth int) error {
if depth < 0 {
return formatValidationError("thread depth", fmt.Sprintf("%d", depth), "must be non-negative")
}

if depth > MaxCommentThreadDepth {
return formatAccessValidationError("comment thread", fmt.Sprintf("nest deeper than %d levels", MaxCommentThreadDepth),
return formatAccessValidationError("comment thread", fmt.Sprintf("nest deeper than %d levels", MaxCommentThreadDepth),
fmt.Sprintf("Deep comment nesting can cause performance issues and poor user experience. Maximum allowed depth is %d levels", MaxCommentThreadDepth))
}

Expand All @@ -316,7 +319,7 @@ func validateMultipleFields(validations []func() ValidationResult) []ValidationR
for _, validate := range validations {
result := validate()
results = append(results, result)

// Stop early if we hit the max validation errors
if len(results) >= MaxValidationErrors {
break
Expand Down
50 changes: 25 additions & 25 deletions cmd/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,25 +25,25 @@ type CommandInfo struct {
type CommandRegistry interface {
// Register adds a command to the registry
Register(info CommandInfo) error

// GetCommand returns a specific command by name
GetCommand(name string) (*cobra.Command, error)

// GetAllCommands returns all registered commands
GetAllCommands() map[string]*cobra.Command

// GetCommandsByCategory returns commands grouped by category
GetCommandsByCategory() map[string][]*CommandInfo

// ListCommands returns a sorted list of command names
ListCommands() []string

// BuildAll builds all registered commands and adds them to the root command
BuildAll(rootCmd *cobra.Command) error

// GetCommandInfo returns metadata for a command
GetCommandInfo(name string) (*CommandInfo, error)

// GetRegisteredCount returns the number of registered commands
GetRegisteredCount() int
}
Expand All @@ -65,23 +65,23 @@ func (r *DefaultCommandRegistry) Register(info CommandInfo) error {
if info.Name == "" {
return fmt.Errorf("command name cannot be empty")
}

if info.Builder == nil {
return fmt.Errorf("command builder cannot be nil for command %s", info.Name)
}

if _, exists := r.commands[info.Name]; exists {
return fmt.Errorf("command %s is already registered", info.Name)
}

// Set defaults
if info.Category == "" {
info.Category = "general"
}
if info.Description == "" {
info.Description = "No description available"
}

r.commands[info.Name] = &info
return nil
}
Expand All @@ -92,22 +92,22 @@ func (r *DefaultCommandRegistry) GetCommand(name string) (*cobra.Command, error)
if !exists {
return nil, fmt.Errorf("command %s not found", name)
}

// Build command if not already cached
if info.Command == nil {
info.Command = info.Builder()
if info.Command == nil {
return nil, fmt.Errorf("command builder for %s returned nil", name)
}
}

return info.Command, nil
}

// GetAllCommands returns all registered commands
func (r *DefaultCommandRegistry) GetAllCommands() map[string]*cobra.Command {
result := make(map[string]*cobra.Command)

for name, info := range r.commands {
if info.Command == nil {
info.Command = info.Builder()
Expand All @@ -116,18 +116,18 @@ func (r *DefaultCommandRegistry) GetAllCommands() map[string]*cobra.Command {
result[name] = info.Command
}
}

return result
}

// GetCommandsByCategory returns commands grouped by category
func (r *DefaultCommandRegistry) GetCommandsByCategory() map[string][]*CommandInfo {
result := make(map[string][]*CommandInfo)

for _, info := range r.commands {
result[info.Category] = append(result[info.Category], info)
}

// Sort each category by priority, then by name
for category := range result {
sort.Slice(result[category], func(i, j int) bool {
Expand All @@ -137,7 +137,7 @@ func (r *DefaultCommandRegistry) GetCommandsByCategory() map[string][]*CommandIn
return result[category][i].Name < result[category][j].Name
})
}

return result
}

Expand All @@ -154,24 +154,24 @@ func (r *DefaultCommandRegistry) ListCommands() []string {
// BuildAll builds all registered commands and adds them to the root command
func (r *DefaultCommandRegistry) BuildAll(rootCmd *cobra.Command) error {
var errors []string

for name, info := range r.commands {
if info.Command == nil {
info.Command = info.Builder()
}

if info.Command == nil {
errors = append(errors, fmt.Sprintf("command %s builder returned nil", name))
continue
}

rootCmd.AddCommand(info.Command)
}

if len(errors) > 0 {
return fmt.Errorf("failed to build commands: %s", strings.Join(errors, "; "))
}

return nil
}

Expand All @@ -181,7 +181,7 @@ func (r *DefaultCommandRegistry) GetCommandInfo(name string) (*CommandInfo, erro
if !exists {
return nil, fmt.Errorf("command %s not found", name)
}

// Return a copy to prevent modification
infoCopy := *info
return &infoCopy, nil
Expand Down Expand Up @@ -216,4 +216,4 @@ func RegisterCommand(info CommandInfo) error {
// BuildAllCommands builds all registered commands and adds them to the root command
func BuildAllCommands(rootCmd *cobra.Command) error {
return GetRegistry().BuildAll(rootCmd)
}
}
Loading
Loading