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 cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"

"github.com/chaoss/ai-detection-action/detection"
"github.com/chaoss/ai-detection-action/detection/assistedby"
"github.com/chaoss/ai-detection-action/detection/coauthor"
"github.com/chaoss/ai-detection-action/detection/committer"
"github.com/chaoss/ai-detection-action/detection/gitnotes"
Expand All @@ -27,6 +28,7 @@ const (

func allDetectors() []detection.Detector {
return []detection.Detector{
&assistedby.Detector{},
&committer.Detector{},
&coauthor.Detector{},
&gitnotes.Detector{},
Expand Down
77 changes: 77 additions & 0 deletions detection/assistedby/assistedby.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package assistedby

import (
"fmt"
"regexp"
"strings"

"github.com/chaoss/ai-detection-action/detection"
)

var assistedByPattern = regexp.MustCompile(`(?im)^assisted-by\s*:\s*([^\r\n]+?)\s*$`)
var toolLineReplacePattern = regexp.MustCompile(`\s*<[^>]+>`)

func getMatchedTools(toolLine string) []string {
toolLine = strings.TrimSpace(toolLine)
if toolLine == "" {
return nil
}
toolLine = toolLineReplacePattern.ReplaceAllString(toolLine, "")
parts := strings.Split(toolLine, "\n")
var tools []string
for _, p := range parts {
p = strings.TrimSpace(strings.Split(strings.TrimSpace(p), "(")[0])
if p == "" {
continue
}
words := strings.Fields(p)
for i, w := range words {
if len(w) > 0 {
words[i] = strings.ToUpper(w[:1]) + w[1:]
}
}
tools = append(tools, strings.Join(words, " "))
}
return tools
}

type Detector struct{}

func (d *Detector) Name() string { return "assistedby" }

func (d *Detector) Detect(input detection.Input) []detection.Finding {
if input.CommitMessage == "" {
return nil
}

matches := assistedByPattern.FindAllStringSubmatch(
input.CommitMessage,
-1,
)

if len(matches) == 0 {
return nil
}

var findings []detection.Finding

for _, match := range matches {
if len(match) < 2 {
continue
}

for _, matchedTool := range getMatchedTools(match[1]) {
findings = append(findings, detection.Finding{
Detector: d.Name(),
Tool: matchedTool,
Confidence: detection.ConfidenceHigh,
Detail: fmt.Sprintf(
"Assisted-By trailer with tool %s",
matchedTool,
),
})
}
}

return findings
}
158 changes: 158 additions & 0 deletions detection/assistedby/assistedby_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package assistedby

import (
"testing"

"github.com/chaoss/ai-detection-action/detection"
)

func TestDetect(t *testing.T) {
d := &Detector{}
tests := []struct {
name string
message string
wantTools []string
}{
{
name: "Claude trailer with Opus model",
message: "fix: update handler\n\nAssisted-By: Claude Opus 4 <noreply@anthropic.com>",
wantTools: []string{"Claude Opus 4"},
},
{
name: "Claude trailer with Sonnet model",
message: "fix: update handler\n\nAssisted-By: Claude Sonnet 4 <noreply@anthropic.com>",
wantTools: []string{"Claude Sonnet 4"},
},
{
name: "Cursor trailer",
message: "refactor: extract method\n\nAssisted-By: Cursor <cursoragent@cursor.com>",
wantTools: []string{"Cursor"},
},
{
name: "Aider trailer with model name",
message: "feat: add endpoint\n\nAssisted-By: aider (gpt-4o) <noreply@aider.chat>",
wantTools: []string{"Aider"},
},
{
name: "Aider trailer with different model",
message: "feat: add endpoint\n\nAssisted-By: aider (claude-4.7-opus) <noreply@aider.chat>",
wantTools: []string{"Aider"},
},
{
name: "multiple trailers with Claude and human",
message: "fix: bug\n\nAssisted-By: Claude Opus 4 <noreply@anthropic.com>",
wantTools: []string{"Claude Opus 4"},
},
{
name: "multiple AI trailers",
message: "fix: bug\n\nAssisted-By: Claude Opus 4 <noreply@anthropic.com>\nAssisted-By: aider (gpt-4o) <noreply@aider.chat>",
wantTools: []string{"Claude Opus 4", "Aider"},
},
{
name: "case variation",
message: "fix: something\n\nassisted-by: Claude <noreply@anthropic.com>",
wantTools: []string{"Claude"},
},
{
name: "ASSISTED-BY uppercase",
message: "fix: something\n\nASSISTED-BY: Claude <noreply@anthropic.com>",
wantTools: []string{"Claude"},
},
{
name: "Assisted-By trailer in commit message",
message: "this is a commit message with\nAssisted-By: Claude Code",
wantTools: []string{"Claude Code"},
},
{
name: "Another Assisted-By trailer in commit message 1",
message: "this is a commit message with\nAssisted-By: Gemini",
wantTools: []string{"Gemini"},
},
{
name: "Another Assisted-By trailer in commit message 2",
message: "this is a commit message with\nAssisted-By: Kimi K2.6",
wantTools: []string{"Kimi K2.6"},
},
{
name: "Multiple Assisted-By trailer in commit message",
message: "this is a commit message with\nAssisted-By: Claude Code\nAssisted-By: Gemini",
wantTools: []string{"Claude Code", "Gemini"},
},
{
name: "Multiple Assisted-By trailers (with purpose brackets) in commit message",
message: `
this is a commit message

Co-Authored-By: Cursor <cursoragent@cursor.com>

Assisted-by: Claude 4.7 Opus
(logic optimization and design fixes)
Assisted-by: Kimi K2.6 (unit tests, integration tests)
Assisted-by: ChatGPT (documentation review)
Assisted-by: Gemini (documentation)
`,
wantTools: []string{"Claude 4.7 Opus", "Kimi K2.6", "ChatGPT", "Gemini"},
},
{
name: "Assisted-By trailer in commit message in lower case",
message: "this is a commit message with\nassisted-by: Claude Code",
wantTools: []string{"Claude Code"},
},
{
name: "Two different attributions (assistedby and coauthor) both with email address",
message: "Fix bug\n\nAssisted-By: Claude Sonnet 4 <noreply@anthropic.com>\nCo-Authored-By: Copilot <copilot@github.com>",
wantTools: []string{"Claude Sonnet 4"},
},
{
name: "Two different attributions (assistedby and coauthor) one with model name, other with email address",
message: "Add validation logic\n\nCo-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>\nAssisted-by: GitHub Copilot",
wantTools: []string{"GitHub Copilot"},
},
{
name: "Claude Opus model attribution trailer",
message: "Fix bug\n\nAssisted-by: Claude Opus 4 <noreply@anthropic.com>",
wantTools: []string{"Claude Opus 4"},
},
{
name: "no trailers",
message: "just a normal commit message",
wantTools: nil,
},
{
name: "empty message",
message: "",
wantTools: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
findings := d.Detect(detection.Input{CommitMessage: tt.message})
gotTools := make([]string, len(findings))
for i, f := range findings {
gotTools[i] = f.Tool
if f.Confidence != detection.ConfidenceHigh {
t.Errorf("confidence = %d, want %d", f.Confidence, detection.ConfidenceHigh)
}
if f.Detector != "assistedby" {
t.Errorf("detector = %q, want %q", f.Detector, "assistedby")
}
}

if len(gotTools) == 0 {
gotTools = nil
}

if len(gotTools) != len(tt.wantTools) {
t.Errorf("tools = %v, want %v", gotTools, tt.wantTools)
return
}
for i := range gotTools {
if gotTools[i] != tt.wantTools[i] {
t.Errorf("tools = %v, want %v", gotTools, tt.wantTools)
return
}
}
})
}
}
66 changes: 51 additions & 15 deletions scan/scan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"time"

"github.com/chaoss/ai-detection-action/detection"
"github.com/chaoss/ai-detection-action/detection/assistedby"
"github.com/chaoss/ai-detection-action/detection/coauthor"
"github.com/chaoss/ai-detection-action/detection/committer"
"github.com/chaoss/ai-detection-action/detection/gitnotes"
Expand All @@ -19,6 +20,7 @@ import (

func allDetectors() []detection.Detector {
return []detection.Detector{
&assistedby.Detector{},
&committer.Detector{},
&coauthor.Detector{},
&gitnotes.Detector{},
Expand Down Expand Up @@ -48,6 +50,19 @@ func initTestRepo(t *testing.T) (string, []string) {
{"initial commit", "human@example.com"},
{"fix: update handler\n\nCo-Authored-By: Claude Opus 4 <noreply@anthropic.com>", "human@example.com"},
{"aider: refactor auth module", "human@example.com"},
{`
this is a commit message

Co-Authored-By: Cursor <cursoragent@cursor.com>

Assisted-by: Claude 4.7 Opus
(logic optimization and design fixes)
Assisted-by: Kimi K2.6 (unit tests, integration tests)
Assisted-by: ChatGPT (documentation review)
Assisted-by: Gemini (documentation)
`,
"human@example.com",
},
}

var hashes []string
Expand Down Expand Up @@ -84,17 +99,17 @@ func TestScanCommitRange(t *testing.T) {
dir, hashes := initTestRepo(t)
detectors := allDetectors()

report, err := ScanCommitRange(dir, hashes[0]+".."+hashes[2], detectors)
report, err := ScanCommitRange(dir, hashes[0]+".."+hashes[3], detectors)
if err != nil {
t.Fatalf("ScanCommitRange: %v", err)
}

if report.Summary.TotalCommits != 2 {
t.Errorf("total commits = %d, want 2", report.Summary.TotalCommits)
if report.Summary.TotalCommits != 3 {
t.Errorf("total commits = %d, want 3", report.Summary.TotalCommits)
}

if report.Summary.AICommits != 2 {
t.Errorf("ai commits = %d, want 2", report.Summary.AICommits)
if report.Summary.AICommits != 3 {
t.Errorf("ai commits = %d, want 3", report.Summary.AICommits)
}

// Check that Claude Code was detected via co-author
Expand All @@ -106,6 +121,21 @@ func TestScanCommitRange(t *testing.T) {
if count, ok := report.Summary.ToolCounts["Aider"]; !ok || count == 0 {
t.Error("expected Aider in tool counts")
}

// Check detection via assisted-by pattern 1
if count, ok := report.Summary.ToolCounts["ChatGPT"]; !ok || count == 0 {
t.Error("expected ChatGPT in tool counts")
}

// Check detection via assisted-by pattern 2
if count, ok := report.Summary.ToolCounts["Claude 4.7 Opus"]; !ok || count == 0 {
t.Error("expected Claude 4.7 Opus in tool counts")
}

// Check detection via assisted-by pattern 3
if count, ok := report.Summary.ToolCounts["Kimi K2.6"]; !ok || count == 0 {
t.Error("expected Kimi K2.6 Opus in tool counts")
}
}

func TestScanCommitRangeAll(t *testing.T) {
Expand All @@ -117,37 +147,43 @@ func TestScanCommitRangeAll(t *testing.T) {
t.Fatalf("ScanCommitRange: %v", err)
}

if report.Summary.TotalCommits != 3 {
t.Errorf("total commits = %d, want 3", report.Summary.TotalCommits)
if report.Summary.TotalCommits != 4 {
t.Errorf("total commits = %d, want 4", report.Summary.TotalCommits)
}
}

func TestScanCommit(t *testing.T) {
dir, hashes := initTestRepo(t)
detectors := allDetectors()

// Scan the commit with co-author trailer
result, err := ScanCommit(dir, hashes[1], detectors)
// Scan the commit with assisted-by and co-author trailers
result, err := ScanCommit(dir, hashes[3], detectors)
if err != nil {
t.Fatalf("ScanCommit: %v", err)
}

if result.Hash != hashes[1] {
t.Errorf("hash = %q, want %q", result.Hash, hashes[1])
if result.Hash != hashes[3] {
t.Errorf("hash = %q, want %q", result.Hash, hashes[3])
}

if len(result.Findings) == 0 {
t.Error("expected findings for co-author commit")
t.Error("expected findings for assisted-by and co-author trailers")
}

foundCoauthor := false
foundAssistedBy := false
for _, f := range result.Findings {
if f.Detector == "coauthor" && f.Tool == "Claude Code" {
if f.Detector == "coauthor" && f.Tool == "Cursor" {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Tool has been changed to Cursor here as commit 4 (instead of 2) is being read now, see this updated test commit which has both Co-Authored-By and multiple Assisted-By.

foundCoauthor = true
} else if f.Detector == "assistedby" && f.Tool == "Kimi K2.6" {
foundAssistedBy = true
}
}
if !foundCoauthor {
t.Error("expected coauthor finding for Claude Code")
t.Error("expected coauthor finding for Cursor")
}
if !foundAssistedBy {
t.Error("expected assistedby finding for Kimi K2.6")
}
}

Expand Down Expand Up @@ -281,7 +317,7 @@ func TestReportSummaryByConfidence(t *testing.T) {
t.Fatalf("ScanCommitRange: %v", err)
}

// Co-author trailer should give high confidence
// Co-author and Assisted-By trailers should give high confidence
if count, ok := report.Summary.ByConfidence["high"]; !ok || count == 0 {
t.Error("expected high confidence findings")
}
Expand Down
Loading