Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0addf8fad |
@@ -377,6 +377,217 @@ func sortTriageQueue(queue []TriageItem) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Issue #117: Tests for GetTriageQueue aggregation ---
|
||||||
|
|
||||||
|
func TestGetTriageQueue_Integration(t *testing.T) {
|
||||||
|
// Mock server that returns issues (some assigned, some not) and PRs.
|
||||||
|
requestCount := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requestCount++
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/api/v1/user/orgs":
|
||||||
|
json.NewEncoder(w).Encode([]Org{{Name: "org1"}})
|
||||||
|
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/api/v1/orgs/org1/repos"):
|
||||||
|
json.NewEncoder(w).Encode([]Repo{
|
||||||
|
{ID: 1, Name: "repo1", FullName: "org1/repo1", Owner: struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
}{Login: "org1"}},
|
||||||
|
})
|
||||||
|
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/issues") && r.Method == "GET":
|
||||||
|
// Return mix of assigned and unassigned issues.
|
||||||
|
issues := []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"id": 1, "number": 1, "title": "Unassigned bug",
|
||||||
|
"state": "open", "assignee": nil, "assignees": []interface{}{},
|
||||||
|
"labels": []map[string]interface{}{{"id": 1, "name": "P1", "color": "ff0000"}},
|
||||||
|
"html_url": "http://example.com/org1/repo1/issues/1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2, "number": 2, "title": "Assigned issue",
|
||||||
|
"state": "open",
|
||||||
|
"assignee": map[string]string{"login": "dev1", "avatar_url": ""},
|
||||||
|
"assignees": []map[string]string{{"login": "dev1", "avatar_url": ""}},
|
||||||
|
"labels": []interface{}{},
|
||||||
|
"html_url": "http://example.com/org1/repo1/issues/2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3, "number": 3, "title": "Unassigned low priority",
|
||||||
|
"state": "open", "assignee": nil, "assignees": []interface{}{},
|
||||||
|
"labels": []map[string]interface{}{{"id": 2, "name": "P3", "color": "00ff00"}},
|
||||||
|
"html_url": "http://example.com/org1/repo1/issues/3",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(issues)
|
||||||
|
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/pulls") && r.Method == "GET":
|
||||||
|
prs := []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"id": 10, "number": 10, "title": "Open PR needs review",
|
||||||
|
"state": "open", "body": "please review",
|
||||||
|
"labels": []map[string]interface{}{{"id": 3, "name": "P2", "color": "ffff00"}},
|
||||||
|
"html_url": "http://example.com/org1/repo1/pulls/10",
|
||||||
|
"head": map[string]string{"label": "feature", "ref": "feature"},
|
||||||
|
"base": map[string]string{"label": "master", "ref": "master"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(prs)
|
||||||
|
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/reviews"):
|
||||||
|
json.NewEncoder(w).Encode([]interface{}{})
|
||||||
|
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
fmt.Fprintf(w, "unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := NewClient(server.URL)
|
||||||
|
queue, err := c.GetTriageQueue(context.Background(), "test-token", []string{"org1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTriageQueue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should include: 2 unassigned issues + 1 PR = 3 items.
|
||||||
|
// Assigned issue (#2) should be excluded.
|
||||||
|
if len(queue) != 3 {
|
||||||
|
t.Fatalf("expected 3 triage items, got %d", len(queue))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify sorting: P1 > P2 > P3.
|
||||||
|
if queue[0].Title != "Unassigned bug" {
|
||||||
|
t.Errorf("queue[0] should be P1 'Unassigned bug', got %q", queue[0].Title)
|
||||||
|
}
|
||||||
|
if queue[1].Title != "Open PR needs review" {
|
||||||
|
t.Errorf("queue[1] should be P2 'Open PR needs review', got %q", queue[1].Title)
|
||||||
|
}
|
||||||
|
if queue[2].Title != "Unassigned low priority" {
|
||||||
|
t.Errorf("queue[2] should be P3 'Unassigned low priority', got %q", queue[2].Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify types.
|
||||||
|
if queue[0].Type != "issue" {
|
||||||
|
t.Errorf("queue[0].Type = %q, want 'issue'", queue[0].Type)
|
||||||
|
}
|
||||||
|
if queue[1].Type != "pull" {
|
||||||
|
t.Errorf("queue[1].Type = %q, want 'pull'", queue[1].Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTriageQueue_EmptyOrgs(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/api/v1/user/orgs":
|
||||||
|
json.NewEncoder(w).Encode([]Org{})
|
||||||
|
default:
|
||||||
|
json.NewEncoder(w).Encode([]interface{}{})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := NewClient(server.URL)
|
||||||
|
queue, err := c.GetTriageQueue(context.Background(), "test-token", []string{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTriageQueue with empty orgs: %v", err)
|
||||||
|
}
|
||||||
|
if len(queue) != 0 {
|
||||||
|
t.Errorf("expected empty queue for empty orgs, got %d items", len(queue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTriageQueue_AllAssigned(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/api/v1/user/orgs":
|
||||||
|
json.NewEncoder(w).Encode([]Org{{Name: "org1"}})
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/api/v1/orgs/org1/repos"):
|
||||||
|
json.NewEncoder(w).Encode([]Repo{
|
||||||
|
{ID: 1, Name: "repo1", FullName: "org1/repo1", Owner: struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
}{Login: "org1"}},
|
||||||
|
})
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/issues"):
|
||||||
|
// All issues are assigned.
|
||||||
|
json.NewEncoder(w).Encode([]map[string]interface{}{
|
||||||
|
{
|
||||||
|
"id": 1, "number": 1, "title": "Assigned issue",
|
||||||
|
"state": "open",
|
||||||
|
"assignee": map[string]string{"login": "dev1"},
|
||||||
|
"assignees": []map[string]string{{"login": "dev1"}},
|
||||||
|
"labels": []interface{}{},
|
||||||
|
"html_url": "http://example.com/org1/repo1/issues/1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/pulls"):
|
||||||
|
json.NewEncoder(w).Encode([]interface{}{}) // No PRs.
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/reviews"):
|
||||||
|
json.NewEncoder(w).Encode([]interface{}{})
|
||||||
|
default:
|
||||||
|
json.NewEncoder(w).Encode([]interface{}{})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := NewClient(server.URL)
|
||||||
|
queue, err := c.GetTriageQueue(context.Background(), "test-token", []string{"org1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTriageQueue: %v", err)
|
||||||
|
}
|
||||||
|
// Only PRs should appear (none here), all issues are assigned.
|
||||||
|
if len(queue) != 0 {
|
||||||
|
t.Errorf("expected 0 items (all assigned), got %d", len(queue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTriageQueue_LabelExtraction(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/api/v1/user/orgs":
|
||||||
|
json.NewEncoder(w).Encode([]Org{{Name: "org1"}})
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/api/v1/orgs/org1/repos"):
|
||||||
|
json.NewEncoder(w).Encode([]Repo{
|
||||||
|
{ID: 1, Name: "repo1", FullName: "org1/repo1", Owner: struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
}{Login: "org1"}},
|
||||||
|
})
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/issues"):
|
||||||
|
json.NewEncoder(w).Encode([]map[string]interface{}{
|
||||||
|
{
|
||||||
|
"id": 1, "number": 1, "title": "Multi-label issue",
|
||||||
|
"state": "open", "assignee": nil, "assignees": []interface{}{},
|
||||||
|
"labels": []map[string]interface{}{
|
||||||
|
{"id": 1, "name": "bug", "color": "d73a4a"},
|
||||||
|
{"id": 2, "name": "P1", "color": "ff0000"},
|
||||||
|
{"id": 3, "name": "help wanted", "color": "0e8a16"},
|
||||||
|
},
|
||||||
|
"html_url": "http://example.com/org1/repo1/issues/1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/pulls"):
|
||||||
|
json.NewEncoder(w).Encode([]interface{}{})
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/reviews"):
|
||||||
|
json.NewEncoder(w).Encode([]interface{}{})
|
||||||
|
default:
|
||||||
|
json.NewEncoder(w).Encode([]interface{}{})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := NewClient(server.URL)
|
||||||
|
queue, err := c.GetTriageQueue(context.Background(), "test-token", []string{"org1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTriageQueue: %v", err)
|
||||||
|
}
|
||||||
|
if len(queue) != 1 {
|
||||||
|
t.Fatalf("expected 1 item, got %d", len(queue))
|
||||||
|
}
|
||||||
|
if len(queue[0].Labels) != 3 {
|
||||||
|
t.Errorf("expected 3 labels, got %d: %v", len(queue[0].Labels), queue[0].Labels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Issue #122: Tests for ListOrgsAndRepos and CreateIssue ---
|
// --- Issue #122: Tests for ListOrgsAndRepos and CreateIssue ---
|
||||||
|
|
||||||
func TestListOrgsAndRepos(t *testing.T) {
|
func TestListOrgsAndRepos(t *testing.T) {
|
||||||
|
|||||||
@@ -181,58 +181,11 @@ func renderPage(w http.ResponseWriter, r *http.Request, title, activeTab string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// errorData holds the template data for error pages.
|
|
||||||
type errorData struct {
|
|
||||||
Code int
|
|
||||||
Title string
|
|
||||||
Message string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ErrorNotFound renders a mobile-friendly 404 error page.
|
|
||||||
func (h *Handler) ErrorNotFound(w http.ResponseWriter, r *http.Request) {
|
|
||||||
data := errorData{
|
|
||||||
Code: http.StatusNotFound,
|
|
||||||
Title: "Page Not Found",
|
|
||||||
Message: "The page you are looking for does not exist or has been moved.",
|
|
||||||
}
|
|
||||||
h.renderError(w, r, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ErrorInternal renders a mobile-friendly 500 error page.
|
|
||||||
func (h *Handler) ErrorInternal(w http.ResponseWriter, r *http.Request) {
|
|
||||||
data := errorData{
|
|
||||||
Code: http.StatusInternalServerError,
|
|
||||||
Title: "Internal Server Error",
|
|
||||||
Message: "Something went wrong on our end. Please try again later.",
|
|
||||||
}
|
|
||||||
h.renderError(w, r, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderError renders the error template with the given data and status code.
|
|
||||||
func (h *Handler) renderError(w http.ResponseWriter, r *http.Request, data errorData) {
|
|
||||||
tmpl, err := template.ParseFiles("internal/templates/error.html")
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to parse error template", "error", err)
|
|
||||||
http.Error(w, fmt.Sprintf("%d %s", data.Code, data.Title), data.Code)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf strings.Builder
|
|
||||||
if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil {
|
|
||||||
slog.Error("failed to execute error template", "error", err)
|
|
||||||
http.Error(w, fmt.Sprintf("%d %s", data.Code, data.Title), data.Code)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(data.Code)
|
|
||||||
renderPage(w, r, data.Title, "", buf.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dashboard handles GET / — the triage queue.
|
// Dashboard handles GET / — the triage queue.
|
||||||
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
// Only handle exact root path.
|
// Only handle exact root path.
|
||||||
if r.URL.Path != "/" {
|
if r.URL.Path != "/" {
|
||||||
h.ErrorNotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,87 +183,6 @@ func TestAddComment_EmptyBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestErrorNotFound(t *testing.T) {
|
|
||||||
h := newTestHandler()
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/nonexistent", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.ErrorNotFound(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusNotFound {
|
|
||||||
t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
|
|
||||||
}
|
|
||||||
body := w.Body.String()
|
|
||||||
if body == "" {
|
|
||||||
t.Error("expected non-empty response body")
|
|
||||||
}
|
|
||||||
if !contains(body, "404") {
|
|
||||||
t.Error("expected body to contain '404'")
|
|
||||||
}
|
|
||||||
if !contains(body, "Page Not Found") {
|
|
||||||
t.Error("expected body to contain 'Page Not Found'")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrorInternal(t *testing.T) {
|
|
||||||
h := newTestHandler()
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/error", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.ErrorInternal(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusInternalServerError {
|
|
||||||
t.Errorf("status = %d, want %d", w.Code, http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
body := w.Body.String()
|
|
||||||
if body == "" {
|
|
||||||
t.Error("expected non-empty response body")
|
|
||||||
}
|
|
||||||
if !contains(body, "500") {
|
|
||||||
t.Error("expected body to contain '500'")
|
|
||||||
}
|
|
||||||
if !contains(body, "Internal Server Error") {
|
|
||||||
t.Error("expected body to contain 'Internal Server Error'")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDashboard_NonRootPath_Returns404(t *testing.T) {
|
|
||||||
h := newTestHandler()
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/unknown/path", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.Dashboard(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusNotFound {
|
|
||||||
t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
|
|
||||||
}
|
|
||||||
body := w.Body.String()
|
|
||||||
if !contains(body, "404") {
|
|
||||||
t.Error("expected body to contain '404' for non-root path")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrorNotFound_HTMX(t *testing.T) {
|
|
||||||
h := newTestHandler()
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/nonexistent", nil)
|
|
||||||
req.Header.Set("HX-Request", "true")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.ErrorNotFound(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusNotFound {
|
|
||||||
t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
|
|
||||||
}
|
|
||||||
body := w.Body.String()
|
|
||||||
// HTMX response should not contain DOCTYPE.
|
|
||||||
if contains(body, "<!DOCTYPE") {
|
|
||||||
t.Error("HTMX response should not contain DOCTYPE")
|
|
||||||
}
|
|
||||||
if !contains(body, "Page Not Found") {
|
|
||||||
t.Error("expected body to contain 'Page Not Found'")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func contains(s, substr string) bool {
|
func contains(s, substr string) bool {
|
||||||
return len(s) >= len(substr) && searchString(s, substr)
|
return len(s) >= len(substr) && searchString(s, substr)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
{{define "content"}}
|
|
||||||
<div class="error-page">
|
|
||||||
<div class="error-icon">
|
|
||||||
{{if eq .Code 404}}
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="64" height="64">
|
|
||||||
<circle cx="11" cy="11" r="8"/>
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
||||||
<line x1="8" y1="11" x2="14" y2="11"/>
|
|
||||||
</svg>
|
|
||||||
{{else}}
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="64" height="64">
|
|
||||||
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
|
|
||||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
|
||||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
|
||||||
</svg>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
<h1 class="error-code">{{.Code}}</h1>
|
|
||||||
<p class="error-title">{{.Title}}</p>
|
|
||||||
<p class="error-message">{{.Message}}</p>
|
|
||||||
<a href="/" class="error-home-link">Go to Dashboard</a>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
@@ -545,56 +545,3 @@ a:active {
|
|||||||
--text-link: #0969da;
|
--text-link: #0969da;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Error page */
|
|
||||||
.error-page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 60vh;
|
|
||||||
text-align: center;
|
|
||||||
padding: var(--spacing-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-icon {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: var(--spacing-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-code {
|
|
||||||
font-size: 4rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary);
|
|
||||||
line-height: 1;
|
|
||||||
margin-bottom: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-title {
|
|
||||||
font-size: var(--font-xl);
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
font-size: var(--font-base);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: var(--spacing-lg);
|
|
||||||
max-width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-home-link {
|
|
||||||
display: inline-block;
|
|
||||||
padding: var(--spacing-sm) var(--spacing-lg);
|
|
||||||
background: var(--accent-blue);
|
|
||||||
color: #fff;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: var(--font-base);
|
|
||||||
font-weight: 500;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-home-link:active {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user