Files
kite 533b526b4c chore: add SPDX license headers and automated verification (#740)
* chore: add SPDX license headers to all source files

Add Apache-2.0 SPDX license identifiers and copyright notices to all
tracked .go, .sh, .js, .mjs, .ts, and .tsx source files.

Introduce scripts/verify-license.sh and scripts/add-license.sh for
automated verification and bulk addition of license headers. Integrate
the check into CI (ci.yml) and the Makefile (license-check target as
a prerequisite of the existing check target).

This satisfies the OpenSSF Best Practices Badge requirements for
copyright_per_file and license_per_file.

* fix: restore execute permissions on scripts

* docs: add license header instructions to CONTRIBUTING guides

* docs: add license header instructions to pages contributing guides

* fix(pages): strip unclosed HTML comment markers to satisfy CodeQL

* fix: apply code review suggestions for license scripts

- Fix portability: detect macOS vs Linux stat for permission copy
- Fix has_header: check both SPDX and copyright (match verify logic)
- Fix is_ignored: match on path boundaries to avoid false positives
- Fix year extraction: use consistent pipeline across both scripts
- Fix Bash 3.2 compat: quote array length expansion for set -u

* fix(pages): use loop-until-clean for HTML comment stripping (CodeQL)

* fix(pages): use split/join instead of replace to avoid CodeQL false positive

CodeQL's js/incomplete-multi-character-sanitization rule flags any
.replace() that removes multi-character sequences like '<!--...-->',
regardless of context. The data here comes from readFileSync on the
project's own index.html (no untrusted input), making this a false
positive. Using split(regex).join('') achieves the same result without
triggering the taint-tracking rule.
2026-08-05 21:26:27 +08:00

242 lines
6.3 KiB
Go

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
package llmloop
import (
"errors"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/alibaba/open-code-review/internal/model"
)
func TestNewCommentWorkerPool_Default(t *testing.T) {
p := NewCommentWorkerPool(0)
if cap(p.semaphore) != 8 {
t.Errorf("default capacity = %d, want 8", cap(p.semaphore))
}
}
func TestNewCommentWorkerPool_Custom(t *testing.T) {
p := NewCommentWorkerPool(4)
if cap(p.semaphore) != 4 {
t.Errorf("capacity = %d, want 4", cap(p.semaphore))
}
}
func TestCommentWorkerPool_SubmitAndAwait(t *testing.T) {
p := NewCommentWorkerPool(2)
p.Submit(func() ([]model.LlmComment, error) {
return []model.LlmComment{{Path: "a.go", Content: "issue 1"}}, nil
})
p.Submit(func() ([]model.LlmComment, error) {
return []model.LlmComment{{Path: "b.go", Content: "issue 2"}, {Path: "b.go", Content: "issue 3"}}, nil
})
results := p.Await()
if len(results) != 3 {
t.Fatalf("expected 3 results, got %d", len(results))
}
paths := map[string]bool{}
for _, r := range results {
paths[r.Path] = true
}
if !paths["a.go"] || !paths["b.go"] {
t.Errorf("unexpected paths: %v", results)
}
}
func TestCommentWorkerPool_ErrorDoesNotBlock(t *testing.T) {
p := NewCommentWorkerPool(2)
p.Submit(func() ([]model.LlmComment, error) {
return nil, errors.New("oops")
})
p.Submit(func() ([]model.LlmComment, error) {
return []model.LlmComment{{Path: "ok.go", Content: "fine"}}, nil
})
results := p.Await()
if len(results) != 1 {
t.Fatalf("expected 1 result after error, got %d", len(results))
}
if results[0].Path != "ok.go" {
t.Errorf("Path = %q", results[0].Path)
}
}
func TestCommentWorkerPool_Concurrency(t *testing.T) {
p := NewCommentWorkerPool(3)
var running atomic.Int32
var maxRunning atomic.Int32
for i := 0; i < 10; i++ {
p.Submit(func() ([]model.LlmComment, error) {
cur := running.Add(1)
for {
old := maxRunning.Load()
if cur <= old || maxRunning.CompareAndSwap(old, cur) {
break
}
}
running.Add(-1)
return nil, nil
})
}
p.Await()
if maxRunning.Load() > 3 {
t.Errorf("max concurrent = %d, expected <= 3", maxRunning.Load())
}
}
func TestCommentWorkerPool_AwaitEmpty(t *testing.T) {
p := NewCommentWorkerPool(2)
results := p.Await()
if results != nil {
t.Errorf("expected nil for no submissions, got %v", results)
}
}
func TestCommentWorkerPool_PanicIsIsolated(t *testing.T) {
p := NewCommentWorkerPool(2)
p.Submit(func() ([]model.LlmComment, error) {
panic("boom in submitted work")
})
p.Submit(func() ([]model.LlmComment, error) {
return []model.LlmComment{{Path: "healthy.go", Content: "fine"}}, nil
})
// Await must not crash: the recovered panic contributes no comments, and the
// healthy task's result is still collected.
results := p.Await()
if len(results) != 1 {
t.Fatalf("expected 1 result after a panicking task, got %d", len(results))
}
if results[0].Path != "healthy.go" {
t.Errorf("Path = %q, want healthy.go", results[0].Path)
}
}
// TestCommentWorkerPool_AwaitKeyWaitsForOwnKey verifies that AwaitKey blocks
// until the units submitted under its key complete, without waiting for units
// registered under other keys.
func TestCommentWorkerPool_AwaitKeyWaitsForOwnKey(t *testing.T) {
p := NewCommentWorkerPool(2)
release := make(chan struct{})
ownDone := make(chan struct{})
p.SubmitFor("own.go", func() ([]model.LlmComment, error) {
close(ownDone)
<-release
return []model.LlmComment{{Path: "own.go", Content: "mine"}}, nil
})
// A slow unit under a different key must not be required for AwaitKey("own.go").
p.SubmitFor("other.go", func() ([]model.LlmComment, error) {
<-release
return []model.LlmComment{{Path: "other.go", Content: "theirs"}}, nil
})
awaitReturned := make(chan struct{})
go func() {
p.AwaitKey("own.go")
close(awaitReturned)
}()
<-ownDone
close(release)
select {
case <-awaitReturned:
case <-time.After(2 * time.Second):
t.Fatal("AwaitKey did not return after its own key's work completed")
}
// The whole pool is drained by Await; both keyed units' results are present.
results := p.Await()
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
}
// TestCommentWorkerPool_AwaitKeyConcurrentSubmitOtherKey is the regression
// test for the review-path race: one file's goroutine draining its async
// comment work while other files' loops keep submitting. A pool-wide Await in
// this pattern misuses sync.WaitGroup ("Add called concurrently with Wait");
// the keyed API must stay panic-free.
func TestCommentWorkerPool_AwaitKeyConcurrentSubmitOtherKey(t *testing.T) {
p := NewCommentWorkerPool(4)
const submissionsPerProducer = 200
start := make(chan struct{})
var submits atomic.Int64
var producerWg sync.WaitGroup
for i := 0; i < 4; i++ {
producerWg.Add(1)
go func() {
defer producerWg.Done()
<-start
for j := 0; j < submissionsPerProducer; j++ {
p.SubmitFor("producer.go", func() ([]model.LlmComment, error) {
return nil, nil
})
submits.Add(1)
}
}()
}
// Concurrently drain per-goroutine keys that occasionally have real work.
// Each key is used by exactly one goroutine (submit-then-await in program
// order), matching the per-file usage in the review path.
var drained atomic.Int64
var drainerWg sync.WaitGroup
for i := 0; i < 4; i++ {
key := fmt.Sprintf("drainer-%d.go", i)
drainerWg.Add(1)
go func() {
defer drainerWg.Done()
<-start
for j := 0; j < 200; j++ {
p.SubmitFor(key, func() ([]model.LlmComment, error) {
return []model.LlmComment{{Path: key}}, nil
})
p.AwaitKey(key)
drained.Add(1)
}
}()
}
close(start)
drainerWg.Wait()
producerWg.Wait()
if drained.Load() != 800 {
t.Errorf("drained = %d, want 800", drained.Load())
}
if want := int64(4 * submissionsPerProducer); submits.Load() != want {
t.Errorf("producer submissions = %d, want %d", submits.Load(), want)
}
p.Await()
}
// TestCommentWorkerPool_AwaitKeyUnknown verifies waiting on a key with no
// submissions returns immediately.
func TestCommentWorkerPool_AwaitKeyUnknown(t *testing.T) {
p := NewCommentWorkerPool(2)
done := make(chan struct{})
go func() {
p.AwaitKey("never-submitted.go")
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("AwaitKey on unknown key blocked")
}
}