Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| faf5fc1797 | |||
| 8c390e7505 | |||
| ffacea132c | |||
| f44390a75a | |||
| a7b777cf7e | |||
| f0addf8fad | |||
| 2ea20da5ef | |||
| 25bc305fc9 |
@@ -0,0 +1,44 @@
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "./tmp/main"
|
||||
cmd = "go build -o ./tmp/main ./cmd/server"
|
||||
delay = 500
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata", ".git", "node_modules"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test\\.go$"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "html", "css", "js"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = false
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
||||
@@ -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 ---
|
||||
|
||||
func TestListOrgsAndRepos(t *testing.T) {
|
||||
|
||||
@@ -181,11 +181,58 @@ 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.
|
||||
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
// Only handle exact root path.
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
h.ErrorNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -183,6 +183,87 @@ 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 {
|
||||
return len(s) >= len(substr) && searchString(s, substr)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{{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,3 +545,56 @@ a:active {
|
||||
--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