356 lines
12 KiB
Go
356 lines
12 KiB
Go
package workflow
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
|
)
|
|
|
|
func TestFetchReviewContextAllSections(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo.json":
|
|
writeWorkflowJSON(t, w, map[string]interface{}{"name": "repo", "default_branch": "master"})
|
|
case r.Method == "GET" && r.URL.Path == "/owner/repo/pulls/7.json":
|
|
writeWorkflowJSON(t, w, map[string]interface{}{
|
|
"pull_request": map[string]interface{}{
|
|
"number": 7,
|
|
"title": "feat: add workflow context",
|
|
"user": map[string]interface{}{"login": "alice"},
|
|
},
|
|
})
|
|
case r.Method == "GET" && r.URL.Path == "/owner/repo/pulls/7/files.json":
|
|
writeWorkflowJSON(t, w, map[string]interface{}{
|
|
"files": []map[string]interface{}{
|
|
{"filename": "shortcuts/workflow/review_context.go", "additions": 120},
|
|
},
|
|
})
|
|
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/7/reviews.json":
|
|
writeWorkflowJSON(t, w, map[string]interface{}{
|
|
"reviews": []map[string]interface{}{
|
|
{"id": 1, "status": "approved", "user": map[string]interface{}{"login": "reviewer"}},
|
|
},
|
|
})
|
|
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
|
|
if got := r.URL.Query().Get("category"); got != "opened" {
|
|
t.Fatalf("issue category = %q, want opened", got)
|
|
}
|
|
if got := r.URL.Query().Get("limit"); got != "2" {
|
|
t.Fatalf("issue limit = %q, want 2", got)
|
|
}
|
|
writeWorkflowJSON(t, w, map[string]interface{}{
|
|
"issues": []map[string]interface{}{
|
|
{"number": 1, "title": "bug: install fails"},
|
|
},
|
|
})
|
|
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issue_tags.json":
|
|
if got := r.URL.Query().Get("limit"); got != "3" {
|
|
t.Fatalf("label limit = %q, want 3", got)
|
|
}
|
|
writeWorkflowJSON(t, w, map[string]interface{}{
|
|
"issue_tags": []map[string]interface{}{
|
|
{"id": 2, "name": "bug"},
|
|
},
|
|
})
|
|
default:
|
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx := workflowTestContext(server)
|
|
got, err := FetchReviewContext(ctx, ReviewContextOptions{
|
|
Number: 7,
|
|
IssueLimit: 2,
|
|
LabelLimit: 3,
|
|
IncludeRepo: true,
|
|
IncludePR: true,
|
|
IncludeFiles: true,
|
|
IncludeReviews: true,
|
|
IncludeIssues: true,
|
|
IncludeLabels: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("FetchReviewContext returned error: %v", err)
|
|
}
|
|
if got.Repository != "owner/repo" || got.PullRequest != 7 {
|
|
t.Fatalf("got repository=%q pr=%d", got.Repository, got.PullRequest)
|
|
}
|
|
if len(got.Sections) != 6 {
|
|
t.Fatalf("sections = %v, want 6 sections", got.Sections)
|
|
}
|
|
if len(got.Files) != 1 || len(got.Reviews) != 1 || len(got.OpenIssues) != 1 || len(got.Labels) != 1 {
|
|
t.Fatalf("context lists not populated: files=%d reviews=%d issues=%d labels=%d", len(got.Files), len(got.Reviews), len(got.OpenIssues), len(got.Labels))
|
|
}
|
|
if len(got.Notes) != 0 {
|
|
t.Fatalf("notes = %+v, want empty", got.Notes)
|
|
}
|
|
}
|
|
|
|
func TestFetchReviewContextPartialFailureKeepsNotes(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/v1/owner/repo.json":
|
|
writeWorkflowJSON(t, w, map[string]interface{}{"name": "repo"})
|
|
case "/owner/repo/pulls/9/files.json":
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte("files unavailable"))
|
|
default:
|
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
got, err := FetchReviewContext(workflowTestContext(server), ReviewContextOptions{
|
|
Number: 9,
|
|
IncludeRepo: true,
|
|
IncludeFiles: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("FetchReviewContext returned error: %v", err)
|
|
}
|
|
if got.RepositoryInfo == nil {
|
|
t.Fatal("expected repository info to be populated")
|
|
}
|
|
if len(got.Notes) != 1 || got.Notes[0].Metric != "pr_files" {
|
|
t.Fatalf("notes = %+v, want one pr_files note", got.Notes)
|
|
}
|
|
}
|
|
|
|
func TestFetchReviewContextEvidenceSectionsAreBounded(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/v1/owner/repo/pulls/12/commits.json":
|
|
if got := r.URL.Query().Get("limit"); got != "2" {
|
|
t.Fatalf("commit limit = %q, want 2", got)
|
|
}
|
|
writeWorkflowJSON(t, w, map[string]interface{}{
|
|
"commits": []map[string]interface{}{
|
|
{"sha": "abc123", "message": "feat: add evidence"},
|
|
{"sha": "def456", "message": "test: cover evidence"},
|
|
{"sha": "ignored", "message": "should be bounded"},
|
|
},
|
|
})
|
|
case "/owner/repo/pulls/12.json":
|
|
writeWorkflowJSON(t, w, map[string]interface{}{
|
|
"pull_request": map[string]interface{}{
|
|
"number": 12,
|
|
"head_branch": "feature/evidence",
|
|
"head_commit_sha": "abcdef1234567",
|
|
},
|
|
})
|
|
case "/owner/repo/builds.json":
|
|
if got := r.URL.Query().Get("limit"); got != "1" {
|
|
t.Fatalf("CI limit = %q, want 1", got)
|
|
}
|
|
writeWorkflowJSON(t, w, map[string]interface{}{
|
|
"builds": []map[string]interface{}{{"id": 9, "sha": "abcdef1", "status": "completed", "conclusion": "success"}, {"id": 10, "sha": "other", "status": "failed"}},
|
|
})
|
|
default:
|
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
got, err := FetchReviewContext(workflowTestContext(server), ReviewContextOptions{
|
|
Number: 12,
|
|
CommitLimit: 2,
|
|
BuildLimit: 1,
|
|
IncludeCommits: true,
|
|
IncludeCI: true,
|
|
IncludePR: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("FetchReviewContext returned error: %v", err)
|
|
}
|
|
if len(got.Commits) != 2 || len(got.Builds) != 1 {
|
|
t.Fatalf("evidence sizes = commits:%d builds:%d, want 2/1", len(got.Commits), len(got.Builds))
|
|
}
|
|
if !strings.Contains(strings.Join(got.Sections, ","), "commits") || !strings.Contains(strings.Join(got.Sections, ","), "ci_builds") {
|
|
t.Fatalf("sections = %v, want commits and ci_builds", got.Sections)
|
|
}
|
|
if got.CISummary == nil || got.CISummary.MatchMode != "sha" || got.CISummary.Matched != 1 || got.CISummary.Passed != 1 || got.CISummary.Failed != 0 || got.CISummary.Unmatched != 0 {
|
|
t.Fatalf("ci summary = %+v, want one matched passing build", got.CISummary)
|
|
}
|
|
}
|
|
|
|
func TestSummarizeReviewCIUsesSHABeforeBranch(t *testing.T) {
|
|
summary := summarizeReviewCI(
|
|
map[string]interface{}{"head_branch": "feature/x", "head_commit_sha": "abcdef1234567"},
|
|
[]map[string]interface{}{
|
|
{"sha": "abcdef1", "status": "completed", "conclusion": "success"},
|
|
{"sha": "different", "branch": "feature/x", "status": "failed"},
|
|
{"sha": "other", "branch": "feature/y", "status": "failed"},
|
|
},
|
|
)
|
|
if summary.MatchMode != "sha" || summary.Matched != 1 || summary.Unmatched != 2 || summary.Passed != 1 || summary.Failed != 0 {
|
|
t.Fatalf("summary = %+v, want SHA-only matching with one pass", summary)
|
|
}
|
|
}
|
|
|
|
func TestSummarizeReviewCIFallsBackToBranch(t *testing.T) {
|
|
summary := summarizeReviewCI(
|
|
map[string]interface{}{"head_branch": "feature/x"},
|
|
[]map[string]interface{}{
|
|
{"ref": "refs/heads/feature/x", "status": "running"},
|
|
{"branch": "feature/y", "status": "success"},
|
|
},
|
|
)
|
|
if summary.MatchMode != "branch" || summary.Matched != 1 || summary.Unmatched != 1 || summary.Pending != 1 || summary.Passed != 0 {
|
|
t.Fatalf("summary = %+v, want branch fallback with one pending build", summary)
|
|
}
|
|
}
|
|
|
|
func TestFetchReviewContextEvidenceFailureIsNonFatal(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/v1/owner/repo.json" {
|
|
writeWorkflowJSON(t, w, map[string]interface{}{"name": "repo"})
|
|
return
|
|
}
|
|
if r.URL.Path == "/owner/repo/builds.json" {
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
_, _ = w.Write([]byte("CI unavailable"))
|
|
return
|
|
}
|
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
|
}))
|
|
defer server.Close()
|
|
|
|
got, err := FetchReviewContext(workflowTestContext(server), ReviewContextOptions{
|
|
Number: 13,
|
|
IncludeRepo: true,
|
|
IncludeCI: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("FetchReviewContext returned error: %v", err)
|
|
}
|
|
if len(got.Notes) != 1 || got.Notes[0].Metric != "ci_builds" {
|
|
t.Fatalf("notes = %+v, want one ci_builds note", got.Notes)
|
|
}
|
|
}
|
|
|
|
func TestFetchReviewContextAllSectionsFail(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte("server error"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
_, err := FetchReviewContext(workflowTestContext(server), ReviewContextOptions{
|
|
Number: 9,
|
|
IncludeRepo: true,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error when all enabled sections fail")
|
|
}
|
|
}
|
|
|
|
func TestReviewContextShortcutRemoteFetchJSON(t *testing.T) {
|
|
restoreFormat := setCommandFormatForTest(t, "json")
|
|
defer restoreFormat()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/v1/owner/repo.json":
|
|
writeWorkflowJSON(t, w, map[string]interface{}{"name": "repo"})
|
|
case "/owner/repo/pulls/3.json":
|
|
writeWorkflowJSON(t, w, map[string]interface{}{"number": 3, "title": "fix: bug"})
|
|
default:
|
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx := &common.RuntimeContext{
|
|
Client: &client.Client{
|
|
HTTP: server.Client(),
|
|
BaseURL: server.URL,
|
|
},
|
|
Owner: "owner",
|
|
Repo: "repo",
|
|
Format: "json",
|
|
Args: map[string]string{
|
|
"number": "3",
|
|
"issue-limit": "20",
|
|
"label-limit": "50",
|
|
"include-repo": "true",
|
|
"include-pr": "true",
|
|
"include-files": "false",
|
|
"include-reviews": "false",
|
|
"include-issues": "false",
|
|
"include-labels": "false",
|
|
},
|
|
}
|
|
|
|
output := captureStdout(t, func() error {
|
|
return findWorkflowShortcut(t, "review-context").Run(ctx)
|
|
})
|
|
var result ReviewContext
|
|
if err := json.Unmarshal([]byte(output), &result); err != nil {
|
|
t.Fatalf("json.Unmarshal returned error: %v\noutput=%s", err, output)
|
|
}
|
|
if result.PullRequest != 3 || result.Repository != "owner/repo" {
|
|
t.Fatalf("result = %+v, want PR 3 owner/repo", result)
|
|
}
|
|
if len(result.Sections) != 2 {
|
|
t.Fatalf("sections = %v, want repo and pr", result.Sections)
|
|
}
|
|
}
|
|
|
|
func TestReviewContextShortcutMissingNumber(t *testing.T) {
|
|
ctx := &common.RuntimeContext{Args: map[string]string{}}
|
|
err := findWorkflowShortcut(t, "review-context").Run(ctx)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing number")
|
|
}
|
|
if !strings.Contains(err.Error(), "requires --number") {
|
|
t.Fatalf("error = %v, want missing number hint", err)
|
|
}
|
|
}
|
|
|
|
func TestRenderReviewContextFormats(t *testing.T) {
|
|
context := ReviewContext{
|
|
Repository: "owner/repo",
|
|
PullRequest: 4,
|
|
Source: "shortcut-backed-read-only-fetch",
|
|
Sections: []string{"repo_info", "pr"},
|
|
Files: []map[string]interface{}{{"filename": "README.md"}},
|
|
Commits: []map[string]interface{}{{"sha": "abc123"}},
|
|
Builds: []map[string]interface{}{{"id": 9, "status": "success"}},
|
|
Notes: []ScoringNote{{Metric: "labels", Note: "label +list equivalent failed"}},
|
|
}
|
|
|
|
table, err := RenderReviewContext(context, "table")
|
|
if err != nil {
|
|
t.Fatalf("RenderReviewContext table returned error: %v", err)
|
|
}
|
|
if !strings.Contains(table, "REPOSITORY") || !strings.Contains(table, "owner/repo") {
|
|
t.Fatalf("table output = %q", table)
|
|
}
|
|
|
|
markdown, err := RenderReviewContext(context, "markdown")
|
|
if err != nil {
|
|
t.Fatalf("RenderReviewContext markdown returned error: %v", err)
|
|
}
|
|
if !strings.Contains(markdown, "# PR Review Context") || !strings.Contains(markdown, "label +list") {
|
|
t.Fatalf("markdown output = %q", markdown)
|
|
}
|
|
if !strings.Contains(markdown, "Commits: `1`") || !strings.Contains(markdown, "CI builds: `1`") {
|
|
t.Fatalf("markdown output missing evidence counts = %q", markdown)
|
|
}
|
|
}
|
|
|
|
func TestParseBoolDefault(t *testing.T) {
|
|
if !parseBoolDefault("", true) {
|
|
t.Fatal("empty value should use true default")
|
|
}
|
|
if parseBoolDefault("false", true) {
|
|
t.Fatal("false value should override true default")
|
|
}
|
|
}
|