Compare commits

..

1 Commits

Author SHA1 Message Date
agent-company 6bb53889da feat: render issue and PR body as markdown via Gitea API
Add RenderMarkdown method to gitea client that calls POST /api/v1/markdown
to convert raw markdown text to safe HTML. Wire it into IssueDetail and
PullDetail handlers to render body content as formatted markdown.
Falls back gracefully to plain text if the API call fails.

Templates updated to use RenderedBody (template.HTML) with fallback
to raw Issue.Body/Pull.Body when rendering fails.

Closes leeworks-agents/gitea-mobile#35

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:47:19 +00:00
19 changed files with 309 additions and 2295 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
go-version: '1.22' go-version: '1.22'
- name: Run tests - name: Run tests
run: go test -race ./... run: go test ./...
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+1 -1
View File
@@ -1,7 +1,7 @@
# Stage 1: Build # Stage 1: Build
FROM golang:1.22-alpine AS builder FROM golang:1.22-alpine AS builder
WORKDIR /app WORKDIR /app
COPY go.mod ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /gitea-mobile ./cmd/server RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /gitea-mobile ./cmd/server
+1 -1
View File
@@ -33,7 +33,7 @@ func main() {
// Apply middleware chain: logging -> auth. // Apply middleware chain: logging -> auth.
var handler http.Handler = mux var handler http.Handler = mux
handler = middleware.Auth(cfg.SessionSecret, cfg.GiteaToken)(handler) handler = middleware.Auth(cfg.SessionSecret)(handler)
handler = middleware.Logging()(handler) handler = middleware.Logging()(handler)
slog.Info("server starting", "addr", cfg.ListenAddr, "gitea_url", cfg.GiteaURL) slog.Info("server starting", "addr", cfg.ListenAddr, "gitea_url", cfg.GiteaURL)
+28 -251
View File
@@ -107,7 +107,6 @@ type PullRequest struct {
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
RepoOwner string `json:"-"` // populated after fetch RepoOwner string `json:"-"` // populated after fetch
RepoName string `json:"-"` // populated after fetch RepoName string `json:"-"` // populated after fetch
ReviewState string `json:"-"` // aggregated review state: "approved", "changes_requested", "pending", or ""
} }
// TriageItem represents an item in the triage queue. // TriageItem represents an item in the triage queue.
@@ -309,60 +308,26 @@ func (c *Client) ListOrgsAndRepos(ctx context.Context, token string) (map[string
return result, nil return result, nil
} }
// PageSize is the number of items returned per page for paginated listings. // ListAllIssues fetches all open issues across all repos in the given orgs,
const PageSize = 20 // using concurrent requests with a semaphore.
func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string) ([]Issue, error) {
// PaginatedIssues holds a page of issues along with pagination metadata. cacheKey := fmt.Sprintf("issues-%s", strings.Join(orgs, ","))
type PaginatedIssues struct {
Issues []Issue
HasMore bool
}
// PaginatedPulls holds a page of pull requests along with pagination metadata.
type PaginatedPulls struct {
Pulls []PullRequest
HasMore bool
}
// ListAllIssues fetches issues across all repos in the given orgs,
// using concurrent requests with a semaphore. Results are paginated.
// The label parameter filters issues by label name (empty string means no filter).
// The repoFilter parameter narrows results to a single repo name (empty means all repos).
func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string, state string, page int, label string, repoFilter string) (PaginatedIssues, error) {
if state == "" {
state = "open"
}
if page < 1 {
page = 1
}
cacheKey := fmt.Sprintf("issues-%s-%s-%s-%s", state, strings.Join(orgs, ","), label, repoFilter)
var allIssues []Issue
if cached, ok := c.getFromCache(cacheKey); ok { if cached, ok := c.getFromCache(cacheKey); ok {
allIssues = cached.([]Issue) return cached.([]Issue), nil
} else { }
// First, collect all repos for the given orgs. // First, collect all repos for the given orgs.
var allRepos []Repo var allRepos []Repo
for _, org := range orgs { for _, org := range orgs {
repos, err := c.ListOrgRepos(ctx, token, org) repos, err := c.ListOrgRepos(ctx, token, org)
if err != nil { if err != nil {
return PaginatedIssues{}, fmt.Errorf("listing repos for %s: %w", org, err) return nil, fmt.Errorf("listing repos for %s: %w", org, err)
} }
allRepos = append(allRepos, repos...) allRepos = append(allRepos, repos...)
} }
// Filter to a single repo if specified.
if repoFilter != "" {
var filtered []Repo
for _, r := range allRepos {
if r.Name == repoFilter {
filtered = append(filtered, r)
}
}
allRepos = filtered
}
// Fan out issue fetching across repos. // Fan out issue fetching across repos.
var allIssues []Issue
var mu sync.Mutex var mu sync.Mutex
sem := make(chan struct{}, c.maxConcurrent) sem := make(chan struct{}, c.maxConcurrent)
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -375,10 +340,7 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string,
sem <- struct{}{} sem <- struct{}{}
defer func() { <-sem }() defer func() { <-sem }()
path := fmt.Sprintf("/repos/%s/issues?state=%s&type=issues&limit=50", r.FullName, state) path := fmt.Sprintf("/repos/%s/issues?state=open&type=issues&limit=50", r.FullName)
if label != "" {
path += "&labels=" + label
}
resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil) resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
if err != nil { if err != nil {
mu.Lock() mu.Lock()
@@ -415,7 +377,7 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string,
wg.Wait() wg.Wait()
if firstErr != nil { if firstErr != nil {
return PaginatedIssues{}, firstErr return nil, firstErr
} }
// Sort by updated time, newest first. // Sort by updated time, newest first.
@@ -424,58 +386,26 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string,
}) })
c.setCache(cacheKey, allIssues) c.setCache(cacheKey, allIssues)
return allIssues, nil
} }
// Paginate. // ListAllPullRequests fetches all open PRs across all repos in the given orgs.
start := (page - 1) * PageSize func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []string) ([]PullRequest, error) {
if start >= len(allIssues) { cacheKey := fmt.Sprintf("pulls-%s", strings.Join(orgs, ","))
return PaginatedIssues{}, nil
}
end := start + PageSize
hasMore := end < len(allIssues)
if end > len(allIssues) {
end = len(allIssues)
}
return PaginatedIssues{Issues: allIssues[start:end], HasMore: hasMore}, nil
}
// ListAllPullRequests fetches PRs across all repos in the given orgs.
// Results are paginated. The label parameter filters PRs by label name.
// The repoFilter parameter narrows results to a single repo name.
func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []string, state string, page int, label string, repoFilter string) (PaginatedPulls, error) {
if state == "" {
state = "open"
}
if page < 1 {
page = 1
}
cacheKey := fmt.Sprintf("pulls-%s-%s-%s-%s", state, strings.Join(orgs, ","), label, repoFilter)
var allPRs []PullRequest
if cached, ok := c.getFromCache(cacheKey); ok { if cached, ok := c.getFromCache(cacheKey); ok {
allPRs = cached.([]PullRequest) return cached.([]PullRequest), nil
} else { }
var allRepos []Repo var allRepos []Repo
for _, org := range orgs { for _, org := range orgs {
repos, err := c.ListOrgRepos(ctx, token, org) repos, err := c.ListOrgRepos(ctx, token, org)
if err != nil { if err != nil {
return PaginatedPulls{}, fmt.Errorf("listing repos for %s: %w", org, err) return nil, fmt.Errorf("listing repos for %s: %w", org, err)
} }
allRepos = append(allRepos, repos...) allRepos = append(allRepos, repos...)
} }
// Filter to a single repo if specified. var allPRs []PullRequest
if repoFilter != "" {
var filtered []Repo
for _, r := range allRepos {
if r.Name == repoFilter {
filtered = append(filtered, r)
}
}
allRepos = filtered
}
var mu sync.Mutex var mu sync.Mutex
sem := make(chan struct{}, c.maxConcurrent) sem := make(chan struct{}, c.maxConcurrent)
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -488,10 +418,7 @@ func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []s
sem <- struct{}{} sem <- struct{}{}
defer func() { <-sem }() defer func() { <-sem }()
path := fmt.Sprintf("/repos/%s/pulls?state=%s&limit=50", r.FullName, state) path := fmt.Sprintf("/repos/%s/pulls?state=open&limit=50", r.FullName)
if label != "" {
path += "&labels=" + label
}
resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil) resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
if err != nil { if err != nil {
mu.Lock() mu.Lock()
@@ -527,7 +454,7 @@ func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []s
wg.Wait() wg.Wait()
if firstErr != nil { if firstErr != nil {
return PaginatedPulls{}, firstErr return nil, firstErr
} }
sort.Slice(allPRs, func(i, j int) bool { sort.Slice(allPRs, func(i, j int) bool {
@@ -535,49 +462,20 @@ func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []s
}) })
c.setCache(cacheKey, allPRs) c.setCache(cacheKey, allPRs)
} return allPRs, nil
// Paginate.
start := (page - 1) * PageSize
if start >= len(allPRs) {
return PaginatedPulls{}, nil
}
end := start + PageSize
hasMore := end < len(allPRs)
if end > len(allPRs) {
end = len(allPRs)
}
return PaginatedPulls{Pulls: allPRs[start:end], HasMore: hasMore}, nil
} }
// GetTriageQueue returns unassigned issues and PRs needing review, sorted by priority. // GetTriageQueue returns unassigned issues and PRs needing review, sorted by priority.
func (c *Client) GetTriageQueue(ctx context.Context, token string, orgs []string) ([]TriageItem, error) { func (c *Client) GetTriageQueue(ctx context.Context, token string, orgs []string) ([]TriageItem, error) {
// Collect all open issues across all pages. issues, err := c.ListAllIssues(ctx, token, orgs)
var issues []Issue
for page := 1; ; page++ {
result, err := c.ListAllIssues(ctx, token, orgs, "open", page, "", "")
if err != nil { if err != nil {
return nil, fmt.Errorf("fetching issues for triage: %w", err) return nil, fmt.Errorf("fetching issues for triage: %w", err)
} }
issues = append(issues, result.Issues...)
if !result.HasMore {
break
}
}
// Collect all open PRs across all pages. prs, err := c.ListAllPullRequests(ctx, token, orgs)
var prs []PullRequest
for page := 1; ; page++ {
result, err := c.ListAllPullRequests(ctx, token, orgs, "open", page, "", "")
if err != nil { if err != nil {
return nil, fmt.Errorf("fetching PRs for triage: %w", err) return nil, fmt.Errorf("fetching PRs for triage: %w", err)
} }
prs = append(prs, result.Pulls...)
if !result.HasMore {
break
}
}
var queue []TriageItem var queue []TriageItem
@@ -785,49 +683,6 @@ func (c *Client) ApplyLabel(ctx context.Context, token, owner, repo string, inde
return nil return nil
} }
// ListCollaborators fetches the list of collaborators (users with access) for a repo.
func (c *Client) ListCollaborators(ctx context.Context, token, owner, repo string) ([]string, error) {
path := fmt.Sprintf("/repos/%s/%s/collaborators?limit=50", owner, repo)
resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("fetching collaborators: %w", err)
}
defer resp.Body.Close()
var users []struct {
Login string `json:"login"`
}
if err := json.NewDecoder(resp.Body).Decode(&users); err != nil {
return nil, fmt.Errorf("decoding collaborators: %w", err)
}
var logins []string
for _, u := range users {
logins = append(logins, u.Login)
}
return logins, nil
}
// AssignIssue sets the assignees on an issue.
func (c *Client) AssignIssue(ctx context.Context, token, owner, repo string, index int64, assignees []string) error {
payload, err := json.Marshal(map[string]interface{}{
"assignees": assignees,
})
if err != nil {
return fmt.Errorf("marshaling assignees: %w", err)
}
path := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, index)
resp, err := c.doRequest(ctx, token, http.MethodPatch, path, strings.NewReader(string(payload)))
if err != nil {
return fmt.Errorf("assigning issue: %w", err)
}
resp.Body.Close()
c.InvalidateAll()
return nil
}
// SubmitReview submits a review on a pull request. // SubmitReview submits a review on a pull request.
func (c *Client) SubmitReview(ctx context.Context, token, owner, repo string, index int64, reviewType, body string) error { func (c *Client) SubmitReview(ctx context.Context, token, owner, repo string, index int64, reviewType, body string) error {
payload := map[string]interface{}{ payload := map[string]interface{}{
@@ -853,20 +708,15 @@ func (c *Client) SubmitReview(ctx context.Context, token, owner, repo string, in
// CloseIssue closes an issue by setting its state to "closed". // CloseIssue closes an issue by setting its state to "closed".
func (c *Client) CloseIssue(ctx context.Context, token, owner, repo string, index int64) error { func (c *Client) CloseIssue(ctx context.Context, token, owner, repo string, index int64) error {
return c.SetIssueState(ctx, token, owner, repo, index, "closed") payload, err := json.Marshal(map[string]string{"state": "closed"})
}
// SetIssueState sets an issue's state (e.g. "open" or "closed").
func (c *Client) SetIssueState(ctx context.Context, token, owner, repo string, index int64, state string) error {
payload, err := json.Marshal(map[string]string{"state": state})
if err != nil { if err != nil {
return fmt.Errorf("marshaling state change: %w", err) return fmt.Errorf("marshaling close request: %w", err)
} }
path := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, index) path := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, index)
resp, err := c.doRequest(ctx, token, http.MethodPatch, path, strings.NewReader(string(payload))) resp, err := c.doRequest(ctx, token, http.MethodPatch, path, strings.NewReader(string(payload)))
if err != nil { if err != nil {
return fmt.Errorf("setting issue state: %w", err) return fmt.Errorf("closing issue: %w", err)
} }
resp.Body.Close() resp.Body.Close()
@@ -874,11 +724,6 @@ func (c *Client) SetIssueState(ctx context.Context, token, owner, repo string, i
return nil return nil
} }
// AddComment creates a comment on an issue and returns the created Comment.
func (c *Client) AddComment(ctx context.Context, token, owner, repo string, index int64, body string) (*Comment, error) {
return c.PostComment(ctx, token, owner, repo, index, body)
}
// PostComment creates a comment on an issue and returns the created Comment. // PostComment creates a comment on an issue and returns the created Comment.
func (c *Client) PostComment(ctx context.Context, token, owner, repo string, index int64, body string) (*Comment, error) { func (c *Client) PostComment(ctx context.Context, token, owner, repo string, index int64, body string) (*Comment, error) {
payload, err := json.Marshal(map[string]string{"body": body}) payload, err := json.Marshal(map[string]string{"body": body})
@@ -945,74 +790,6 @@ func (c *Client) RenderMarkdown(ctx context.Context, token, text string) (string
return string(rendered), nil return string(rendered), nil
} }
// Review represents a single review on a pull request.
type Review struct {
ID int64 `json:"id"`
Body string `json:"body"`
State string `json:"state"` // "APPROVED", "REQUEST_CHANGES", "COMMENT", "PENDING"
User struct {
Login string `json:"login"`
} `json:"user"`
}
// GetPullReviewState fetches reviews for a PR and returns the aggregate state.
// Priority: changes_requested > approved > pending > "" (no reviews).
func (c *Client) GetPullReviewState(ctx context.Context, token, owner, repo string, index int64) string {
path := fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews?limit=50", owner, repo, index)
resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
if err != nil {
return ""
}
defer resp.Body.Close()
var reviews []Review
if err := json.NewDecoder(resp.Body).Decode(&reviews); err != nil {
return ""
}
if len(reviews) == 0 {
return ""
}
// Aggregate: last non-comment review per user wins, then pick the "worst" state.
userState := make(map[string]string)
for _, r := range reviews {
switch r.State {
case "APPROVED", "REQUEST_CHANGES":
userState[r.User.Login] = r.State
}
}
if len(userState) == 0 {
return "pending"
}
for _, s := range userState {
if s == "REQUEST_CHANGES" {
return "changes_requested"
}
}
return "approved"
}
// EnrichPullsWithReviewState fetches review state for each PR concurrently.
func (c *Client) EnrichPullsWithReviewState(ctx context.Context, token string, pulls []PullRequest) {
sem := make(chan struct{}, c.maxConcurrent)
var wg sync.WaitGroup
for i := range pulls {
wg.Add(1)
go func(idx int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
pulls[idx].ReviewState = c.GetPullReviewState(ctx, token, pulls[idx].RepoOwner, pulls[idx].RepoName, pulls[idx].Number)
}(i)
}
wg.Wait()
}
// priorityScore returns a numeric score for sorting (lower = higher priority). // priorityScore returns a numeric score for sorting (lower = higher priority).
func priorityScore(labels []string) int { func priorityScore(labels []string) int {
for _, l := range labels { for _, l := range labels {
File diff suppressed because it is too large Load Diff
+7 -367
View File
@@ -38,14 +38,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Issues. // Issues.
mux.HandleFunc("GET /issues", h.ListIssues) mux.HandleFunc("GET /issues", h.ListIssues)
mux.HandleFunc("GET /issues/new", h.NewIssue)
mux.HandleFunc("GET /issues/new/labels", h.NewIssueLabels)
mux.HandleFunc("POST /issues", h.CreateIssue) mux.HandleFunc("POST /issues", h.CreateIssue)
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/labels", h.ApplyLabels) mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/labels", h.ApplyLabels)
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/assignees", h.AssignIssue)
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/close", h.CloseIssue) mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/close", h.CloseIssue)
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/state", h.SetIssueState)
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/comments", h.AddComment)
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/comment", h.AddComment) mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/comment", h.AddComment)
// Issue detail. // Issue detail.
@@ -55,7 +50,6 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /pulls", h.ListPulls) mux.HandleFunc("GET /pulls", h.ListPulls)
mux.HandleFunc("GET /pulls/{owner}/{repo}/{index}", h.PullDetail) mux.HandleFunc("GET /pulls/{owner}/{repo}/{index}", h.PullDetail)
mux.HandleFunc("POST /pulls/{owner}/{repo}/{index}/review", h.SubmitReview) mux.HandleFunc("POST /pulls/{owner}/{repo}/{index}/review", h.SubmitReview)
mux.HandleFunc("POST /pulls/{owner}/{repo}/{index}/state", h.SetPullState)
// Settings (handled separately for auth bypass). // Settings (handled separately for auth bypass).
settingsHandler := &SettingsHandler{ settingsHandler := &SettingsHandler{
@@ -120,10 +114,6 @@ var basePage = template.Must(template.New("base").Parse(`<!DOCTYPE html>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
</head> </head>
<body> <body>
<header class="top-bar">
<span class="top-bar-title">Gitea Mobile</span>
<button class="refresh-btn" hx-get="" hx-target="#main-content" hx-swap="innerHTML" aria-label="Refresh">&#8635;</button>
</header>
<div class="content" id="main-content"> <div class="content" id="main-content">
{{.Content}} {{.Content}}
</div> </div>
@@ -191,30 +181,18 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
token := getToken(r) token := getToken(r)
orgs := h.getUserOrgs(r) orgs := h.getUserOrgs(r)
selectedOrg := r.URL.Query().Get("org")
type dashboardData struct { type dashboardData struct {
Items []giteaclient.TriageItem Items []giteaclient.TriageItem
Orgs []string
SelectedOrg string
Error string Error string
} }
data := dashboardData{ var data dashboardData
Orgs: orgs,
SelectedOrg: selectedOrg,
}
if len(orgs) == 0 { if len(orgs) == 0 {
data.Error = "No organizations found. Check your token permissions." data.Error = "No organizations found. Check your token permissions."
} else { } else {
// Determine which orgs to query. queue, err := h.Client.GetTriageQueue(r.Context(), token, orgs)
queryOrgs := orgs
if selectedOrg != "" {
queryOrgs = []string{selectedOrg}
}
queue, err := h.Client.GetTriageQueue(r.Context(), token, queryOrgs)
if err != nil { if err != nil {
slog.Error("failed to get triage queue", "error", err) slog.Error("failed to get triage queue", "error", err)
data.Error = "Error loading triage queue." data.Error = "Error loading triage queue."
@@ -250,9 +228,6 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
Orgs []string Orgs []string
SelectedOrg string SelectedOrg string
SelectedState string SelectedState string
SelectedLabel string
SelectedRepo string
Repos []string
HasMore bool HasMore bool
NextPage int NextPage int
Error string Error string
@@ -263,19 +238,11 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
if selectedState == "" { if selectedState == "" {
selectedState = "open" selectedState = "open"
} }
selectedLabel := r.URL.Query().Get("label")
selectedRepo := r.URL.Query().Get("repo")
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
data := issuesData{ data := issuesData{
Orgs: orgNames, Orgs: orgNames,
SelectedOrg: selectedOrg, SelectedOrg: selectedOrg,
SelectedState: selectedState, SelectedState: selectedState,
SelectedLabel: selectedLabel,
SelectedRepo: selectedRepo,
} }
if len(orgNames) == 0 { if len(orgNames) == 0 {
@@ -285,49 +252,16 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
queryOrgs := orgNames queryOrgs := orgNames
if selectedOrg != "" { if selectedOrg != "" {
queryOrgs = []string{selectedOrg} queryOrgs = []string{selectedOrg}
// Populate repo list for the selected org.
repos, err := h.Client.ListOrgRepos(r.Context(), token, selectedOrg)
if err != nil {
slog.Warn("failed to list repos for org filter", "error", err, "org", selectedOrg)
} else {
for _, repo := range repos {
data.Repos = append(data.Repos, repo.Name)
}
}
} }
result, err := h.Client.ListAllIssues(r.Context(), token, queryOrgs, selectedState, page, selectedLabel, selectedRepo) issues, err := h.Client.ListAllIssues(r.Context(), token, queryOrgs)
if err != nil { if err != nil {
slog.Error("failed to list issues", "error", err) slog.Error("failed to list issues", "error", err)
data.Error = "Error loading issues." data.Error = "Error loading issues."
} else { } else {
data.Issues = result.Issues data.Issues = issues
data.HasMore = result.HasMore
if result.HasMore {
data.NextPage = page + 1
} }
} }
}
// For HTMX infinite-scroll requests (page > 1), return only the card fragment.
if isHTMX(r) && page > 1 {
tmpl, err := template.ParseFiles("internal/templates/issues.html")
if err != nil {
slog.Error("failed to parse issues template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
var buf strings.Builder
if err := tmpl.ExecuteTemplate(&buf, "cards", data); err != nil {
slog.Error("failed to execute issues cards template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, buf.String())
return
}
tmpl, err := template.ParseFiles("internal/templates/issues.html") tmpl, err := template.ParseFiles("internal/templates/issues.html")
if err != nil { if err != nil {
@@ -355,33 +289,14 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
Pulls []giteaclient.PullRequest Pulls []giteaclient.PullRequest
Orgs []string Orgs []string
SelectedOrg string SelectedOrg string
SelectedState string
SelectedLabel string
SelectedRepo string
Repos []string
HasMore bool
NextPage int
Error string Error string
} }
selectedOrg := r.URL.Query().Get("org") selectedOrg := r.URL.Query().Get("org")
selectedState := r.URL.Query().Get("state")
if selectedState == "" {
selectedState = "open"
}
selectedLabel := r.URL.Query().Get("label")
selectedRepo := r.URL.Query().Get("repo")
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
data := pullsData{ data := pullsData{
Orgs: orgNames, Orgs: orgNames,
SelectedOrg: selectedOrg, SelectedOrg: selectedOrg,
SelectedState: selectedState,
SelectedLabel: selectedLabel,
SelectedRepo: selectedRepo,
} }
if len(orgNames) == 0 { if len(orgNames) == 0 {
@@ -390,52 +305,15 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
queryOrgs := orgNames queryOrgs := orgNames
if selectedOrg != "" { if selectedOrg != "" {
queryOrgs = []string{selectedOrg} queryOrgs = []string{selectedOrg}
// Populate repo list for the selected org.
repos, err := h.Client.ListOrgRepos(r.Context(), token, selectedOrg)
if err != nil {
slog.Warn("failed to list repos for org filter", "error", err, "org", selectedOrg)
} else {
for _, repo := range repos {
data.Repos = append(data.Repos, repo.Name)
}
}
} }
result, err := h.Client.ListAllPullRequests(r.Context(), token, queryOrgs, selectedState, page, selectedLabel, selectedRepo) prs, err := h.Client.ListAllPullRequests(r.Context(), token, queryOrgs)
if err != nil { if err != nil {
slog.Error("failed to list pull requests", "error", err) slog.Error("failed to list pull requests", "error", err)
data.Error = "Error loading pull requests." data.Error = "Error loading pull requests."
} else { } else {
data.Pulls = result.Pulls data.Pulls = prs
data.HasMore = result.HasMore
if result.HasMore {
data.NextPage = page + 1
} }
// Enrich PRs with review state for status icons.
if len(data.Pulls) > 0 {
h.Client.EnrichPullsWithReviewState(r.Context(), token, data.Pulls)
}
}
}
// For HTMX infinite-scroll requests (page > 1), return only the card fragment.
if isHTMX(r) && page > 1 {
tmpl, err := template.ParseFiles("internal/templates/pulls.html")
if err != nil {
slog.Error("failed to parse pulls template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
var buf strings.Builder
if err := tmpl.ExecuteTemplate(&buf, "cards", data); err != nil {
slog.Error("failed to execute pulls cards template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, buf.String())
return
} }
tmpl, err := template.ParseFiles("internal/templates/pulls.html") tmpl, err := template.ParseFiles("internal/templates/pulls.html")
@@ -488,12 +366,6 @@ func (h *Handler) IssueDetail(w http.ResponseWriter, r *http.Request) {
labels = nil labels = nil
} }
collaborators, err := h.Client.ListCollaborators(r.Context(), token, owner, repo)
if err != nil {
slog.Error("failed to get collaborators", "error", err)
collaborators = nil
}
// Render markdown body if present. // Render markdown body if present.
var renderedBody template.HTML var renderedBody template.HTML
if issue.Body != "" { if issue.Body != "" {
@@ -518,7 +390,6 @@ func (h *Handler) IssueDetail(w http.ResponseWriter, r *http.Request) {
RenderedBody template.HTML RenderedBody template.HTML
Comments []giteaclient.Comment Comments []giteaclient.Comment
AvailableLabels []giteaclient.Label AvailableLabels []giteaclient.Label
Collaborators []string
} }
data := templateData{ data := templateData{
@@ -526,7 +397,6 @@ func (h *Handler) IssueDetail(w http.ResponseWriter, r *http.Request) {
RenderedBody: renderedBody, RenderedBody: renderedBody,
Comments: comments, Comments: comments,
AvailableLabels: labels, AvailableLabels: labels,
Collaborators: collaborators,
} }
var buf strings.Builder var buf strings.Builder
@@ -571,13 +441,6 @@ func (h *Handler) PullDetail(w http.ResponseWriter, r *http.Request) {
} }
} }
// Fetch comments for this PR (Gitea uses the issues endpoint for PR comments).
comments, err := h.Client.GetIssueComments(r.Context(), token, owner, repo, index)
if err != nil {
slog.Warn("failed to fetch PR comments", "error", err, "owner", owner, "repo", repo, "index", index)
// Non-fatal: continue rendering without comments.
}
// Build the content HTML using the template. // Build the content HTML using the template.
tmpl, err := template.ParseFiles("internal/templates/pull_detail.html") tmpl, err := template.ParseFiles("internal/templates/pull_detail.html")
if err != nil { if err != nil {
@@ -589,13 +452,11 @@ func (h *Handler) PullDetail(w http.ResponseWriter, r *http.Request) {
type templateData struct { type templateData struct {
Pull *giteaclient.PullRequest Pull *giteaclient.PullRequest
RenderedBody template.HTML RenderedBody template.HTML
Comments []giteaclient.Comment
} }
data := templateData{ data := templateData{
Pull: pr, Pull: pr,
RenderedBody: renderedBody, RenderedBody: renderedBody,
Comments: comments,
} }
var buf strings.Builder var buf strings.Builder
@@ -608,73 +469,6 @@ func (h *Handler) PullDetail(w http.ResponseWriter, r *http.Request) {
renderPage(w, r, fmt.Sprintf("PR #%d", index), "pulls", buf.String()) renderPage(w, r, fmt.Sprintf("PR #%d", index), "pulls", buf.String())
} }
// NewIssue handles GET /issues/new — renders the create-issue form.
func (h *Handler) NewIssue(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
repos, err := h.Client.ListOrgsAndRepos(r.Context(), token)
if err != nil {
slog.Error("failed to list repos for new issue form", "error", err)
renderPage(w, r, "New Issue", "issues",
`<h1>New Issue</h1><p class="empty">Error loading repositories.</p>`)
return
}
tmpl, err := template.ParseFiles("internal/templates/create_issue.html")
if err != nil {
slog.Error("failed to parse create_issue template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
type templateData struct {
Repos map[string][]giteaclient.Repo
}
data := templateData{Repos: repos}
var buf strings.Builder
if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil {
slog.Error("failed to execute create_issue template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
renderPage(w, r, "New Issue", "issues", buf.String())
}
// NewIssueLabels handles GET /issues/new/labels — returns label checkboxes for a repo.
func (h *Handler) NewIssueLabels(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
owner := r.URL.Query().Get("owner")
repo := r.URL.Query().Get("repo")
if owner == "" || repo == "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<span class="empty">Select a repository first.</span>`)
return
}
labels, err := h.Client.GetRepoLabels(r.Context(), token, owner, repo)
if err != nil {
slog.Error("failed to fetch labels", "error", err, "owner", owner, "repo", repo)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<span class="empty">Error loading labels.</span>`)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if len(labels) == 0 {
fmt.Fprint(w, `<span class="empty">No labels available for this repository.</span>`)
return
}
for _, l := range labels {
fmt.Fprintf(w, `<label style="display:inline-block;margin:0.25rem 0.5rem 0.25rem 0;cursor:pointer;"><input type="checkbox" name="label_ids" value="%d" style="margin-right:0.25rem;"> <span class="label" style="color:#%s;border:1px solid #%s">%s</span></label>`,
l.ID, template.HTMLEscapeString(l.Color), template.HTMLEscapeString(l.Color), template.HTMLEscapeString(l.Name))
}
}
// CreateIssue handles POST /issues. // CreateIssue handles POST /issues.
func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
token := getToken(r) token := getToken(r)
@@ -688,35 +482,14 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
title := r.FormValue("title") title := r.FormValue("title")
body := r.FormValue("body") body := r.FormValue("body")
// Parse label IDs from form checkboxes.
var labelIDs []int64
for _, idStr := range r.Form["label_ids"] {
id, err := strconv.ParseInt(idStr, 10, 64)
if err == nil {
labelIDs = append(labelIDs, id)
}
}
if owner == "" || repo == "" || title == "" { if owner == "" || repo == "" || title == "" {
if isHTMX(r) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `<span class="empty">owner, repo, and title are required</span>`)
return
}
http.Error(w, "owner, repo, and title are required", http.StatusBadRequest) http.Error(w, "owner, repo, and title are required", http.StatusBadRequest)
return return
} }
issue, err := h.Client.CreateIssue(r.Context(), token, owner, repo, title, body, labelIDs) issue, err := h.Client.CreateIssue(r.Context(), token, owner, repo, title, body, nil)
if err != nil { if err != nil {
slog.Error("failed to create issue", "error", err) slog.Error("failed to create issue", "error", err)
if isHTMX(r) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, `<span class="empty">Failed to create issue. Please try again.</span>`)
return
}
http.Error(w, "failed to create issue", http.StatusInternalServerError) http.Error(w, "failed to create issue", http.StatusInternalServerError)
return return
} }
@@ -777,45 +550,6 @@ func (h *Handler) ApplyLabels(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther) http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
} }
// AssignIssue handles POST /issues/{owner}/{repo}/{index}/assignees.
func (h *Handler) AssignIssue(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
owner := r.PathValue("owner")
repo := r.PathValue("repo")
indexStr := r.PathValue("index")
index, err := strconv.ParseInt(indexStr, 10, 64)
if err != nil {
http.Error(w, "invalid issue index", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
assignee := r.FormValue("assignee")
if assignee == "" {
http.Error(w, "assignee is required", http.StatusBadRequest)
return
}
if err := h.Client.AssignIssue(r.Context(), token, owner, repo, index, []string{assignee}); err != nil {
slog.Error("failed to assign issue", "error", err, "owner", owner, "repo", repo, "index", index, "assignee", assignee)
http.Error(w, "failed to assign issue", http.StatusInternalServerError)
return
}
if isHTMX(r) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<span style="color:#3fb950">Assigned to %s</span>`, template.HTMLEscapeString(assignee))
return
}
http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
}
// CloseIssue handles POST /issues/{owner}/{repo}/{index}/close. // CloseIssue handles POST /issues/{owner}/{repo}/{index}/close.
func (h *Handler) CloseIssue(w http.ResponseWriter, r *http.Request) { func (h *Handler) CloseIssue(w http.ResponseWriter, r *http.Request) {
token := getToken(r) token := getToken(r)
@@ -844,100 +578,6 @@ func (h *Handler) CloseIssue(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther) http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
} }
// SetIssueState handles POST /issues/{owner}/{repo}/{index}/state.
func (h *Handler) SetIssueState(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
owner := r.PathValue("owner")
repo := r.PathValue("repo")
indexStr := r.PathValue("index")
index, err := strconv.ParseInt(indexStr, 10, 64)
if err != nil {
http.Error(w, "invalid issue index", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
state := r.FormValue("state")
if state != "open" && state != "closed" {
http.Error(w, "state must be 'open' or 'closed'", http.StatusBadRequest)
return
}
if err := h.Client.SetIssueState(r.Context(), token, owner, repo, index, state); err != nil {
slog.Error("failed to set issue state", "error", err, "owner", owner, "repo", repo, "index", index, "state", state)
http.Error(w, "failed to update issue state", http.StatusInternalServerError)
return
}
if isHTMX(r) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if state == "closed" {
fmt.Fprintf(w, `<span class="state-closed" id="issue-state">closed</span>
<button class="btn btn-secondary" hx-post="/issues/%s/%s/%d/state" hx-vals='{"state":"open"}' hx-target="#state-section" hx-swap="innerHTML">Reopen Issue</button>`,
template.HTMLEscapeString(owner), template.HTMLEscapeString(repo), index)
} else {
fmt.Fprintf(w, `<span class="state-open" id="issue-state">open</span>
<button class="btn btn-danger" hx-post="/issues/%s/%s/%d/state" hx-vals='{"state":"closed"}' hx-target="#state-section" hx-swap="innerHTML">Close Issue</button>`,
template.HTMLEscapeString(owner), template.HTMLEscapeString(repo), index)
}
return
}
http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
}
// SetPullState handles POST /pulls/{owner}/{repo}/{index}/state.
func (h *Handler) SetPullState(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
owner := r.PathValue("owner")
repo := r.PathValue("repo")
indexStr := r.PathValue("index")
index, err := strconv.ParseInt(indexStr, 10, 64)
if err != nil {
http.Error(w, "invalid pull request index", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
state := r.FormValue("state")
if state != "open" && state != "closed" {
http.Error(w, "state must be 'open' or 'closed'", http.StatusBadRequest)
return
}
if err := h.Client.SetIssueState(r.Context(), token, owner, repo, index, state); err != nil {
slog.Error("failed to set pull request state", "error", err, "owner", owner, "repo", repo, "index", index, "state", state)
http.Error(w, "failed to update pull request state", http.StatusInternalServerError)
return
}
if isHTMX(r) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if state == "closed" {
fmt.Fprintf(w, `<span class="state-closed" id="pull-state">closed</span>
<button class="btn btn-secondary" hx-post="/pulls/%s/%s/%d/state" hx-vals='{"state":"open"}' hx-target="#state-section" hx-swap="innerHTML">Reopen PR</button>`,
template.HTMLEscapeString(owner), template.HTMLEscapeString(repo), index)
} else {
fmt.Fprintf(w, `<span class="state-open" id="pull-state">open</span>
<button class="btn btn-danger" hx-post="/pulls/%s/%s/%d/state" hx-vals='{"state":"closed"}' hx-target="#state-section" hx-swap="innerHTML">Close PR</button>`,
template.HTMLEscapeString(owner), template.HTMLEscapeString(repo), index)
}
return
}
http.Redirect(w, r, fmt.Sprintf("/pulls/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
}
// AddComment handles POST /issues/{owner}/{repo}/{index}/comment. // AddComment handles POST /issues/{owner}/{repo}/{index}/comment.
func (h *Handler) AddComment(w http.ResponseWriter, r *http.Request) { func (h *Handler) AddComment(w http.ResponseWriter, r *http.Request) {
token := getToken(r) token := getToken(r)
-48
View File
@@ -135,54 +135,6 @@ func TestSubmitReview_MissingEventType(t *testing.T) {
} }
} }
func TestSetIssueState_InvalidState(t *testing.T) {
h := newTestHandler()
mux := http.NewServeMux()
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/state", h.SetIssueState)
req := httptest.NewRequest(http.MethodPost, "/issues/org/repo/1/state", nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func TestSetIssueState_InvalidIndex(t *testing.T) {
h := newTestHandler()
mux := http.NewServeMux()
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/state", h.SetIssueState)
req := httptest.NewRequest(http.MethodPost, "/issues/org/repo/abc/state", nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func TestAddComment_EmptyBody(t *testing.T) {
h := newTestHandler()
mux := http.NewServeMux()
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/comments", h.AddComment)
req := httptest.NewRequest(http.MethodPost, "/issues/org/repo/1/comments", nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
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)
} }
+86 -16
View File
@@ -2,7 +2,6 @@ package handlers
import ( import (
"html/template" "html/template"
"log/slog"
"net/http" "net/http"
"strings" "strings"
@@ -10,7 +9,89 @@ import (
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/middleware" "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/middleware"
) )
const settingsTemplatePath = "internal/templates/settings.html" var settingsTemplate = template.Must(template.New("settings").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Settings — Gitea Mobile</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0d1117; color: #e6edf3;
padding: 1rem;
padding-top: max(1rem, env(safe-area-inset-top));
}
h1 { font-size: 1.5rem; margin-bottom: 1rem; }
.card {
background: #161b22; border: 1px solid #30363d; border-radius: 8px;
padding: 1rem; margin-bottom: 1rem;
}
label { display: block; font-size: 0.875rem; color: #8b949e; margin-bottom: 0.5rem; }
input[type="text"], input[type="password"] {
width: 100%; padding: 0.5rem; font-size: 1rem;
background: #0d1117; border: 1px solid #30363d; border-radius: 6px;
color: #e6edf3; margin-bottom: 1rem;
}
input:focus { outline: none; border-color: #58a6ff; }
button {
width: 100%; padding: 0.75rem; font-size: 1rem; font-weight: 600;
background: #238636; color: #fff; border: none; border-radius: 6px;
cursor: pointer;
}
button:active { background: #2ea043; }
.message {
padding: 0.75rem; border-radius: 6px; margin-bottom: 1rem;
font-size: 0.875rem;
}
.message.success { background: #0d2818; border: 1px solid #238636; color: #3fb950; }
.message.error { background: #2d1117; border: 1px solid #da3633; color: #f85149; }
.message.info { background: #0c1d2e; border: 1px solid #1f6feb; color: #58a6ff; }
.hint { font-size: 0.75rem; color: #8b949e; margin-top: 0.25rem; margin-bottom: 1rem; }
.status { font-size: 0.875rem; color: #8b949e; }
.status .connected { color: #3fb950; }
.logout-btn {
background: #21262d; border: 1px solid #30363d; margin-top: 0.5rem;
}
.logout-btn:active { background: #30363d; }
</style>
</head>
<body>
<h1>Settings</h1>
{{if .Message}}
<div class="message {{.MessageType}}">{{.Message}}</div>
{{end}}
{{if .HasToken}}
<div class="card">
<p class="status">Status: <span class="connected">Connected</span></p>
<p class="hint">A Gitea API token is configured.</p>
<form method="POST" action="/settings">
<input type="hidden" name="action" value="logout">
<button type="submit" class="logout-btn">Remove Token</button>
</form>
</div>
{{end}}
<div class="card">
<form method="POST" action="/settings">
<input type="hidden" name="action" value="save">
<label for="token">Gitea API Token</label>
<input type="password" id="token" name="token" placeholder="Enter your Gitea API token" required>
<p class="hint">Generate a token at your Gitea instance under Settings &rarr; Applications.</p>
<button type="submit">{{if .HasToken}}Update Token{{else}}Save Token{{end}}</button>
</form>
</div>
{{if .HasToken}}
<p style="text-align:center; margin-top:1rem;">
<a href="/" style="color:#58a6ff; text-decoration:none;">Back to Dashboard</a>
</p>
{{end}}
</body>
</html>`))
// SettingsHandler handles GET and POST requests for the settings page. // SettingsHandler handles GET and POST requests for the settings page.
type SettingsHandler struct { type SettingsHandler struct {
@@ -45,7 +126,8 @@ func (h *SettingsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
} }
data := settingsData{HasToken: hasToken} data := settingsData{HasToken: hasToken}
h.renderSettings(w, data) w.Header().Set("Content-Type", "text/html; charset=utf-8")
settingsTemplate.Execute(w, data)
} }
func (h *SettingsHandler) handlePost(w http.ResponseWriter, r *http.Request) { func (h *SettingsHandler) handlePost(w http.ResponseWriter, r *http.Request) {
@@ -90,18 +172,6 @@ func (h *SettingsHandler) renderWithMessage(w http.ResponseWriter, r *http.Reque
Message: msg, Message: msg,
MessageType: msgType, MessageType: msgType,
} }
h.renderSettings(w, data)
}
func (h *SettingsHandler) renderSettings(w http.ResponseWriter, data settingsData) {
tmpl, err := template.ParseFiles(settingsTemplatePath)
if err != nil {
slog.Error("failed to parse settings template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil { settingsTemplate.Execute(w, data)
slog.Error("failed to execute settings template", "error", err)
}
} }
+1 -11
View File
@@ -23,12 +23,9 @@ func TokenFromContext(ctx context.Context) string {
} }
// Auth returns middleware that checks for a valid token cookie. // Auth returns middleware that checks for a valid token cookie.
// If no cookie token is found and fallbackToken is non-empty, the fallback
// token is used instead (useful for single-user or service-account deployments
// where GITEA_TOKEN is set in the environment).
// Unauthenticated requests are redirected to the settings page. // Unauthenticated requests are redirected to the settings page.
// The /health, /settings, and /static/ paths are exempt from auth. // The /health, /settings, and /static/ paths are exempt from auth.
func Auth(sessionSecret, fallbackToken string) func(http.Handler) http.Handler { func Auth(sessionSecret string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip auth for exempt paths. // Skip auth for exempt paths.
@@ -40,13 +37,6 @@ func Auth(sessionSecret, fallbackToken string) func(http.Handler) http.Handler {
token, err := auth.GetToken(r, sessionSecret) token, err := auth.GetToken(r, sessionSecret)
if err != nil || token == "" { if err != nil || token == "" {
// Fall back to environment token if available.
if fallbackToken != "" {
slog.Debug("using fallback token from environment", "path", path)
ctx := context.WithValue(r.Context(), TokenContextKey, fallbackToken)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
slog.Debug("unauthenticated request, redirecting to settings", "path", path, "error", err) slog.Debug("unauthenticated request, redirecting to settings", "path", path, "error", err)
http.Redirect(w, r, "/settings", http.StatusSeeOther) http.Redirect(w, r, "/settings", http.StatusSeeOther)
return return
+4 -73
View File
@@ -11,7 +11,7 @@ import (
const testSecret = "test-secret-that-is-at-least-32-chars-long" const testSecret = "test-secret-that-is-at-least-32-chars-long"
func TestAuth_HealthBypass(t *testing.T) { func TestAuth_HealthBypass(t *testing.T) {
handler := Auth(testSecret, "")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := Auth(testSecret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
})) }))
@@ -25,7 +25,7 @@ func TestAuth_HealthBypass(t *testing.T) {
} }
func TestAuth_SettingsBypass(t *testing.T) { func TestAuth_SettingsBypass(t *testing.T) {
handler := Auth(testSecret, "")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := Auth(testSecret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
})) }))
@@ -39,7 +39,7 @@ func TestAuth_SettingsBypass(t *testing.T) {
} }
func TestAuth_RedirectWithoutToken(t *testing.T) { func TestAuth_RedirectWithoutToken(t *testing.T) {
handler := Auth(testSecret, "")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := Auth(testSecret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
})) }))
@@ -57,7 +57,7 @@ func TestAuth_RedirectWithoutToken(t *testing.T) {
func TestAuth_PassWithToken(t *testing.T) { func TestAuth_PassWithToken(t *testing.T) {
called := false called := false
handler := Auth(testSecret, "")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := Auth(testSecret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true called = true
token := TokenFromContext(r.Context()) token := TokenFromContext(r.Context())
if token != "my-token" { if token != "my-token" {
@@ -83,72 +83,3 @@ func TestAuth_PassWithToken(t *testing.T) {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK) t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
} }
} }
func TestAuth_FallbackToken_UsedWhenNoCookie(t *testing.T) {
called := false
handler := Auth(testSecret, "env-fallback-token")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
token := TokenFromContext(r.Context())
if token != "env-fallback-token" {
t.Errorf("token = %q, want %q", token, "env-fallback-token")
}
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if !called {
t.Error("next handler was not called with fallback token")
}
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
}
func TestAuth_FallbackToken_CookieTakesPrecedence(t *testing.T) {
called := false
handler := Auth(testSecret, "env-fallback-token")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
token := TokenFromContext(r.Context())
if token != "cookie-token" {
t.Errorf("token = %q, want %q (cookie should take precedence over fallback)", token, "cookie-token")
}
w.WriteHeader(http.StatusOK)
}))
// Set a cookie token.
cookieW := httptest.NewRecorder()
auth.SetTokenCookie(cookieW, "cookie-token", testSecret, false)
cookie := cookieW.Result().Cookies()[0]
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if !called {
t.Error("next handler was not called")
}
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
}
func TestAuth_NoFallbackToken_RedirectsWithoutCookie(t *testing.T) {
handler := Auth(testSecret, "")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/issues", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want %d", w.Code, http.StatusSeeOther)
}
if loc := w.Header().Get("Location"); loc != "/settings" {
t.Errorf("Location = %q, want %q", loc, "/settings")
}
}
+11 -94
View File
@@ -1,29 +1,23 @@
{{define "content"}} {{define "content"}}
<h1>Create Issue</h1> <h1>Create Issue</h1>
<div id="form-error" class="empty" style="display:none;"></div> <form hx-post="/issues" hx-swap="innerHTML" hx-target="#main-content">
<form id="create-issue-form" hx-post="/issues" hx-swap="innerHTML" hx-target="#main-content">
<div class="form-group"> <div class="form-group">
<label for="repo-select">Repository</label> <label for="repo-select">Repository</label>
<input list="repo-options" id="repo-select" name="owner_repo" <select id="repo-select" name="owner_repo" required>
placeholder="Type to search repositories..." required autocomplete="off"> <option value="">Select a repository...</option>
<datalist id="repo-options">
{{range $org, $repos := .Repos}} {{range $org, $repos := .Repos}}
<optgroup label="{{$org}}">
{{range $repos}} {{range $repos}}
<option value="{{.Owner.Login}}/{{.Name}}">{{.FullName}}</option> <option value="{{.Owner.Login}}/{{.Name}}">{{.FullName}}</option>
{{end}} {{end}}
</optgroup>
{{end}} {{end}}
</datalist> </select>
<input type="hidden" name="owner" id="owner-input"> <input type="hidden" name="owner" id="owner-input">
<input type="hidden" name="repo" id="repo-input"> <input type="hidden" name="repo" id="repo-input">
</div> </div>
<div class="form-group" id="label-section" style="display:none;">
<label>Labels</label>
<div id="label-list"></div>
</div>
<div class="form-group"> <div class="form-group">
<label for="title">Title</label> <label for="title">Title</label>
<input type="text" id="title" name="title" placeholder="Issue title" required> <input type="text" id="title" name="title" placeholder="Issue title" required>
@@ -38,88 +32,11 @@
</form> </form>
<script> <script>
(function() { // Split owner/repo from select into hidden fields.
var repoSelect = document.getElementById('repo-select'); document.getElementById('repo-select').addEventListener('change', function() {
var ownerInput = document.getElementById('owner-input'); var parts = this.value.split('/');
var repoInput = document.getElementById('repo-input'); document.getElementById('owner-input').value = parts[0] || '';
var formError = document.getElementById('form-error'); document.getElementById('repo-input').value = parts[1] || '';
// Build a set of valid repo values for validation.
var validRepos = {};
var options = document.getElementById('repo-options').options;
for (var i = 0; i < options.length; i++) {
validRepos[options[i].value] = true;
}
function splitOwnerRepo() {
var val = repoSelect.value;
if (val && val.indexOf('/') !== -1) {
var parts = val.split('/');
ownerInput.value = parts[0] || '';
repoInput.value = parts[1] || '';
} else {
ownerInput.value = '';
repoInput.value = '';
}
}
var debounceTimer = null;
repoSelect.addEventListener('input', function() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function() {
splitOwnerRepo();
var labelSection = document.getElementById('label-section');
var labelList = document.getElementById('label-list');
if (ownerInput.value && repoInput.value && validRepos[repoSelect.value]) {
labelList.innerHTML = '<span class="empty">Loading labels...</span>';
labelSection.style.display = 'block';
htmx.ajax('GET', '/issues/new/labels?owner=' + encodeURIComponent(ownerInput.value) + '&repo=' + encodeURIComponent(repoInput.value), {target: '#label-list', swap: 'innerHTML'});
} else {
labelSection.style.display = 'none';
labelList.innerHTML = '';
}
}, 300);
}); });
// Also handle the change event for when a datalist option is selected directly.
repoSelect.addEventListener('change', function() {
clearTimeout(debounceTimer);
splitOwnerRepo();
var labelSection = document.getElementById('label-section');
var labelList = document.getElementById('label-list');
if (ownerInput.value && repoInput.value && validRepos[repoSelect.value]) {
labelList.innerHTML = '<span class="empty">Loading labels...</span>';
labelSection.style.display = 'block';
htmx.ajax('GET', '/issues/new/labels?owner=' + encodeURIComponent(ownerInput.value) + '&repo=' + encodeURIComponent(repoInput.value), {target: '#label-list', swap: 'innerHTML'});
} else {
labelSection.style.display = 'none';
labelList.innerHTML = '';
}
});
// Validate before HTMX submit.
document.getElementById('create-issue-form').addEventListener('htmx:configRequest', function(evt) {
splitOwnerRepo();
if (!ownerInput.value || !repoInput.value) {
evt.preventDefault();
formError.textContent = 'Please select a repository before submitting.';
formError.style.display = 'block';
return false;
}
if (!validRepos[repoSelect.value]) {
evt.preventDefault();
formError.textContent = 'Please select a valid repository from the list.';
formError.style.display = 'block';
return false;
}
formError.style.display = 'none';
});
// Show server-side errors inline on HTMX error responses.
document.getElementById('create-issue-form').addEventListener('htmx:responseError', function(evt) {
formError.textContent = evt.detail.xhr.responseText || 'An error occurred. Please try again.';
formError.style.display = 'block';
});
})();
</script> </script>
{{end}} {{end}}
-11
View File
@@ -1,17 +1,6 @@
{{define "content"}} {{define "content"}}
<h1>Dashboard</h1> <h1>Dashboard</h1>
{{if gt (len .Orgs) 1}}
<div class="filter-bar">
<select name="org" hx-get="/" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true">
<option value="">All orgs</option>
{{range .Orgs}}
<option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
{{end}}
{{if .Error}} {{if .Error}}
<p class="empty">{{.Error}}</p> <p class="empty">{{.Error}}</p>
{{else if not .Items}} {{else if not .Items}}
+1 -33
View File
@@ -3,15 +3,7 @@
<div class="card"> <div class="card">
<div class="card-meta"> <div class="card-meta">
<span id="state-section"> <span class="state-open">{{.Issue.State}}</span>
{{if eq .Issue.State "closed"}}
<span class="state-closed" id="issue-state">{{.Issue.State}}</span>
<button class="btn btn-secondary" hx-post="/issues/{{.Issue.RepoOwner}}/{{.Issue.RepoName}}/{{.Issue.Number}}/state" hx-vals='{"state":"open"}' hx-target="#state-section" hx-swap="innerHTML">Reopen Issue</button>
{{else}}
<span class="state-open" id="issue-state">{{.Issue.State}}</span>
<button class="btn btn-danger" hx-post="/issues/{{.Issue.RepoOwner}}/{{.Issue.RepoName}}/{{.Issue.Number}}/state" hx-vals='{"state":"closed"}' hx-target="#state-section" hx-swap="innerHTML">Close Issue</button>
{{end}}
</span>
<span>{{.Issue.RepoOwner}}/{{.Issue.RepoName}} #{{.Issue.Number}}</span> <span>{{.Issue.RepoOwner}}/{{.Issue.RepoName}} #{{.Issue.Number}}</span>
{{range .Issue.Labels}} {{range .Issue.Labels}}
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span> <span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
@@ -26,7 +18,6 @@
{{if .Comments}} {{if .Comments}}
<h2>Comments</h2> <h2>Comments</h2>
<div id="comments-list">
{{range .Comments}} {{range .Comments}}
<div class="comment"> <div class="comment">
<div class="comment-header"> <div class="comment-header">
@@ -36,33 +27,10 @@
<div class="comment-body">{{.Body}}</div> <div class="comment-body">{{.Body}}</div>
</div> </div>
{{end}} {{end}}
</div>
{{else}}
<div id="comments-list"></div>
{{end}} {{end}}
<div class="card" style="margin-top:1rem;">
<h2>Add Comment</h2>
<form hx-post="/issues/{{.Issue.RepoOwner}}/{{.Issue.RepoName}}/{{.Issue.Number}}/comments" hx-target="#comments-list" hx-swap="beforeend" hx-on::after-request="if(event.detail.successful) this.reset()">
<textarea name="body" rows="4" placeholder="Write a comment..." required style="width:100%;margin-bottom:0.5rem;"></textarea>
<button type="submit" class="btn btn-primary" style="width:auto;padding:0.5rem 1rem;">Comment</button>
</form>
</div>
<div class="card" style="margin-top:1rem;"> <div class="card" style="margin-top:1rem;">
<h2>Actions</h2> <h2>Actions</h2>
{{if .Collaborators}}
<form hx-post="/issues/{{.Issue.RepoOwner}}/{{.Issue.RepoName}}/{{.Issue.Number}}/assignees" hx-swap="outerHTML" style="margin-bottom:0.5rem;">
<div class="filter-bar" style="margin-bottom:0.5rem;">
<select name="assignee">
{{range .Collaborators}}
<option value="{{.}}">{{.}}</option>
{{end}}
</select>
<button type="submit" class="btn btn-secondary" style="width:auto;padding:0.5rem 1rem;">Assign</button>
</div>
</form>
{{end}}
<form hx-post="/issues/{{.Issue.RepoOwner}}/{{.Issue.RepoName}}/{{.Issue.Number}}/labels" hx-swap="outerHTML" style="margin-bottom:0.5rem;"> <form hx-post="/issues/{{.Issue.RepoOwner}}/{{.Issue.RepoName}}/{{.Issue.Number}}/labels" hx-swap="outerHTML" style="margin-bottom:0.5rem;">
<div class="filter-bar" style="margin-bottom:0.5rem;"> <div class="filter-bar" style="margin-bottom:0.5rem;">
<select name="label_id"> <select name="label_id">
+24 -37
View File
@@ -1,4 +1,25 @@
{{define "cards"}} {{define "content"}}
<h1>Issues</h1>
<div class="filter-bar">
<select name="org" hx-get="/issues" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='state']">
<option value="">All orgs</option>
{{range .Orgs}}
<option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option>
{{end}}
</select>
<select name="state" hx-get="/issues" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org']">
<option value="open" {{if eq .SelectedState "open"}}selected{{end}}>Open</option>
<option value="closed" {{if eq .SelectedState "closed"}}selected{{end}}>Closed</option>
</select>
</div>
{{if .Error}}
<p class="empty">{{.Error}}</p>
{{else if not .Issues}}
<p class="empty">No issues found.</p>
{{else}}
<div id="issue-list">
{{range .Issues}} {{range .Issues}}
<div class="card" hx-get="/issues/{{.RepoOwner}}/{{.RepoName}}/{{.Number}}" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true"> <div class="card" hx-get="/issues/{{.RepoOwner}}/{{.RepoName}}/{{.Number}}" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true">
<div class="card-title">{{.Title}}</div> <div class="card-title">{{.Title}}</div>
@@ -8,50 +29,16 @@
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span> <span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
{{end}} {{end}}
{{if .Assignee}} {{if .Assignee}}
<img src="{{.Assignee.AvatarURL}}" alt="{{.Assignee.Login}}" class="avatar" title="Assigned to {{.Assignee.Login}}"> <span>{{.Assignee.Login}}</span>
{{end}} {{end}}
</div> </div>
</div> </div>
{{end}} {{end}}
{{if .HasMore}} {{if .HasMore}}
<div class="scroll-sentinel" hx-get="/issues?page={{.NextPage}}&org={{.SelectedOrg}}&state={{.SelectedState}}&label={{.SelectedLabel}}&repo={{.SelectedRepo}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this"> <div class="scroll-sentinel" hx-get="/issues?page={{.NextPage}}&org={{.SelectedOrg}}&state={{.SelectedState}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this">
<div class="spinner htmx-indicator"></div> <div class="spinner htmx-indicator"></div>
</div> </div>
{{end}} {{end}}
{{end}}
{{define "content"}}
<h1>Issues</h1>
<div class="filter-bar">
<select name="org" hx-get="/issues" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='state'],[name='label']">
<option value="">All orgs</option>
{{range .Orgs}}
<option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option>
{{end}}
</select>
{{if .Repos}}
<select name="repo" hx-get="/issues" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org'],[name='state'],[name='label']">
<option value="">All repos</option>
{{range .Repos}}<option value="{{.}}" {{if eq . $.SelectedRepo}}selected{{end}}>{{.}}</option>{{end}}
</select>
{{end}}
<select name="state" hx-get="/issues" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org'],[name='repo'],[name='label']">
<option value="open" {{if eq .SelectedState "open"}}selected{{end}}>Open</option>
<option value="closed" {{if eq .SelectedState "closed"}}selected{{end}}>Closed</option>
</select>
<input type="text" name="label" placeholder="Filter by label..." value="{{.SelectedLabel}}"
hx-get="/issues" hx-trigger="input changed delay:400ms" hx-target="#main-content"
hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org'],[name='state'],[name='repo']">
</div>
{{if .Error}}
<p class="empty">{{.Error}}</p>
{{else if not .Issues}}
<p class="empty">No issues found.</p>
{{else}}
<div id="issue-list">
{{template "cards" .}}
</div> </div>
{{end}} {{end}}
{{end}} {{end}}
-4
View File
@@ -13,10 +13,6 @@
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
</head> </head>
<body> <body>
<header class="top-bar">
<span class="top-bar-title">Gitea Mobile</span>
<button class="refresh-btn" hx-get="" hx-target="#main-content" hx-swap="innerHTML" aria-label="Refresh">&#8635;</button>
</header>
<div class="content" id="main-content"> <div class="content" id="main-content">
{{template "content" .}} {{template "content" .}}
</div> </div>
+1 -29
View File
@@ -4,7 +4,7 @@
<div class="card"> <div class="card">
<div class="card-meta"> <div class="card-meta">
<span class="type-badge type-pull">PR</span> <span class="type-badge type-pull">PR</span>
{{if eq .Pull.State "closed"}}<span class="state-closed">{{.Pull.State}}</span>{{else}}<span class="state-open">{{.Pull.State}}</span>{{end}} <span class="state-open">{{.Pull.State}}</span>
<span>{{.Pull.RepoOwner}}/{{.Pull.RepoName}} #{{.Pull.Number}}</span> <span>{{.Pull.RepoOwner}}/{{.Pull.RepoName}} #{{.Pull.Number}}</span>
{{range .Pull.Labels}} {{range .Pull.Labels}}
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span> <span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
@@ -15,17 +15,6 @@
<span class="diff-del">-{{.Pull.Deletions}}</span> <span class="diff-del">-{{.Pull.Deletions}}</span>
{{if .Pull.Mergeable}}<span style="color:var(--accent-green);">Mergeable</span>{{end}} {{if .Pull.Mergeable}}<span style="color:var(--accent-green);">Mergeable</span>{{end}}
</div> </div>
<div class="card-meta" style="margin-top:0.5rem;">
<span id="state-section">
{{if eq .Pull.State "closed"}}
<span class="state-closed" id="pull-state">{{.Pull.State}}</span>
<button class="btn btn-secondary" hx-post="/pulls/{{.Pull.RepoOwner}}/{{.Pull.RepoName}}/{{.Pull.Number}}/state" hx-vals='{"state":"open"}' hx-target="#state-section" hx-swap="innerHTML">Reopen PR</button>
{{else}}
<span class="state-open" id="pull-state">{{.Pull.State}}</span>
<button class="btn btn-danger" hx-post="/pulls/{{.Pull.RepoOwner}}/{{.Pull.RepoName}}/{{.Pull.Number}}/state" hx-vals='{"state":"closed"}' hx-target="#state-section" hx-swap="innerHTML">Close PR</button>
{{end}}
</span>
</div>
{{if .RenderedBody}} {{if .RenderedBody}}
<div class="card-body markdown-body">{{.RenderedBody}}</div> <div class="card-body markdown-body">{{.RenderedBody}}</div>
{{else if .Pull.Body}} {{else if .Pull.Body}}
@@ -57,21 +46,4 @@
<button type="submit" class="btn btn-primary">Submit Review</button> <button type="submit" class="btn btn-primary">Submit Review</button>
</form> </form>
</div> </div>
{{if .Comments}}
<h2>Comments</h2>
<div id="comments-list">
{{range .Comments}}
<div class="comment">
<div class="comment-header">
<strong>{{.User}}</strong>
<span>{{.CreatedAt}}</span>
</div>
<div class="comment-body">{{.Body}}</div>
</div>
{{end}}
</div>
{{else}}
<p class="empty" style="margin-top:1rem;">No comments yet.</p>
{{end}}
{{end}} {{end}}
+19 -47
View File
@@ -1,4 +1,21 @@
{{define "cards"}} {{define "content"}}
<h1>Pull Requests</h1>
<div class="filter-bar">
<select name="org" hx-get="/pulls" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true">
<option value="">All orgs</option>
{{range .Orgs}}
<option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
{{if .Error}}
<p class="empty">{{.Error}}</p>
{{else if not .Pulls}}
<p class="empty">No open pull requests found.</p>
{{else}}
<div id="pull-list">
{{range .Pulls}} {{range .Pulls}}
<div class="card" hx-get="/pulls/{{.RepoOwner}}/{{.RepoName}}/{{.Number}}" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true"> <div class="card" hx-get="/pulls/{{.RepoOwner}}/{{.RepoName}}/{{.Number}}" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true">
<div class="card-title"> <div class="card-title">
@@ -12,55 +29,10 @@
{{end}} {{end}}
<span class="diff-add">+{{.Additions}}</span> <span class="diff-add">+{{.Additions}}</span>
<span class="diff-del">-{{.Deletions}}</span> <span class="diff-del">-{{.Deletions}}</span>
{{if eq .ReviewState "approved"}}<span class="review-badge review-approved" title="Approved">&#10003;</span> {{if .Mergeable}}<span style="color:var(--accent-green);font-size:0.7rem;">mergeable</span>{{end}}
{{else if eq .ReviewState "changes_requested"}}<span class="review-badge review-changes" title="Changes requested">&#10007;</span>
{{else if eq .ReviewState "pending"}}<span class="review-badge review-pending" title="Awaiting review">&#9202;</span>
{{end}}
{{if .Mergeable}}<span class="merge-badge merge-ready" title="Ready to merge">&#9654; Ready</span>
{{else}}<span class="merge-badge merge-conflicts" title="Has conflicts or not mergeable">Conflicts</span>
{{end}}
</div> </div>
</div> </div>
{{end}} {{end}}
{{if .HasMore}}
<div class="scroll-sentinel" hx-get="/pulls?page={{.NextPage}}&org={{.SelectedOrg}}&state={{.SelectedState}}&label={{.SelectedLabel}}&repo={{.SelectedRepo}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this">
<div class="spinner htmx-indicator"></div>
</div>
{{end}}
{{end}}
{{define "content"}}
<h1>Pull Requests</h1>
<div class="filter-bar">
<select name="org" hx-get="/pulls" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='state'],[name='label']">
<option value="">All orgs</option>
{{range .Orgs}}
<option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option>
{{end}}
</select>
{{if .Repos}}
<select name="repo" hx-get="/pulls" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org'],[name='state'],[name='label']">
<option value="">All repos</option>
{{range .Repos}}<option value="{{.}}" {{if eq . $.SelectedRepo}}selected{{end}}>{{.}}</option>{{end}}
</select>
{{end}}
<select name="state" hx-get="/pulls" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org'],[name='repo'],[name='label']">
<option value="open" {{if eq .SelectedState "open"}}selected{{end}}>Open</option>
<option value="closed" {{if eq .SelectedState "closed"}}selected{{end}}>Closed</option>
</select>
<input type="text" name="label" placeholder="Filter by label..." value="{{.SelectedLabel}}"
hx-get="/pulls" hx-trigger="input changed delay:400ms" hx-target="#main-content"
hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org'],[name='state'],[name='repo']">
</div>
{{if .Error}}
<p class="empty">{{.Error}}</p>
{{else if not .Pulls}}
<p class="empty">No {{.SelectedState}} pull requests found.</p>
{{else}}
<div id="pull-list">
{{template "cards" .}}
</div> </div>
{{end}} {{end}}
{{end}} {{end}}
-83
View File
@@ -1,83 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Settings — Gitea Mobile</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0d1117; color: #e6edf3;
padding: 1rem;
padding-top: max(1rem, env(safe-area-inset-top));
}
h1 { font-size: 1.5rem; margin-bottom: 1rem; }
.card {
background: #161b22; border: 1px solid #30363d; border-radius: 8px;
padding: 1rem; margin-bottom: 1rem;
}
label { display: block; font-size: 0.875rem; color: #8b949e; margin-bottom: 0.5rem; }
input[type="text"], input[type="password"] {
width: 100%; padding: 0.5rem; font-size: 1rem;
background: #0d1117; border: 1px solid #30363d; border-radius: 6px;
color: #e6edf3; margin-bottom: 1rem;
}
input:focus { outline: none; border-color: #58a6ff; }
button {
width: 100%; padding: 0.75rem; font-size: 1rem; font-weight: 600;
background: #238636; color: #fff; border: none; border-radius: 6px;
cursor: pointer;
}
button:active { background: #2ea043; }
.message {
padding: 0.75rem; border-radius: 6px; margin-bottom: 1rem;
font-size: 0.875rem;
}
.message.success { background: #0d2818; border: 1px solid #238636; color: #3fb950; }
.message.error { background: #2d1117; border: 1px solid #da3633; color: #f85149; }
.message.info { background: #0c1d2e; border: 1px solid #1f6feb; color: #58a6ff; }
.hint { font-size: 0.75rem; color: #8b949e; margin-top: 0.25rem; margin-bottom: 1rem; }
.status { font-size: 0.875rem; color: #8b949e; }
.status .connected { color: #3fb950; }
.logout-btn {
background: #21262d; border: 1px solid #30363d; margin-top: 0.5rem;
}
.logout-btn:active { background: #30363d; }
</style>
</head>
<body>
<h1>Settings</h1>
{{if .Message}}
<div class="message {{.MessageType}}">{{.Message}}</div>
{{end}}
{{if .HasToken}}
<div class="card">
<p class="status">Status: <span class="connected">Connected</span></p>
<p class="hint">A Gitea API token is configured.</p>
<form method="POST" action="/settings">
<input type="hidden" name="action" value="logout">
<button type="submit" class="logout-btn">Remove Token</button>
</form>
</div>
{{end}}
<div class="card">
<form method="POST" action="/settings">
<input type="hidden" name="action" value="save">
<label for="token">Gitea API Token</label>
<input type="password" id="token" name="token" placeholder="Enter your Gitea API token" required>
<p class="hint">Generate a token at your Gitea instance under Settings &rarr; Applications.</p>
<button type="submit">{{if .HasToken}}Update Token{{else}}Save Token{{end}}</button>
</form>
</div>
{{if .HasToken}}
<p style="text-align:center; margin-top:1rem;">
<a href="/" style="color:#58a6ff; text-decoration:none;">Back to Dashboard</a>
</p>
{{end}}
</body>
</html>
+3 -73
View File
@@ -47,53 +47,10 @@ body {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
/* Top bar */
.top-bar {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-sm) var(--spacing-lg);
padding-top: max(var(--spacing-sm), env(safe-area-inset-top));
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.top-bar-title {
font-size: var(--font-base);
font-weight: 600;
color: var(--text-primary);
}
.refresh-btn {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.4rem;
padding: var(--spacing-xs) var(--spacing-sm);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
line-height: 1;
border-radius: var(--radius-sm);
transition: color 0.15s ease, background 0.15s ease;
}
.refresh-btn:active {
color: var(--accent-blue);
background: var(--bg-tertiary);
}
.refresh-btn.htmx-request {
animation: spin 0.6s linear infinite;
pointer-events: none;
}
/* Content area */ /* Content area */
.content { .content {
padding: var(--spacing-lg); padding: var(--spacing-lg);
padding-top: var(--spacing-lg); padding-top: max(var(--spacing-lg), env(safe-area-inset-top));
max-width: 640px; max-width: 640px;
margin: 0 auto; margin: 0 auto;
} }
@@ -419,29 +376,6 @@ a:active {
vertical-align: middle; vertical-align: middle;
} }
/* Review status badges */
.review-badge {
font-size: 0.7rem;
font-weight: 600;
padding: 1px 4px;
border-radius: var(--radius-pill);
vertical-align: middle;
}
.review-approved { color: var(--accent-green); }
.review-changes { color: var(--accent-red); }
.review-pending { color: var(--accent-yellow); }
/* Merge status badges */
.merge-badge {
font-size: 0.65rem;
font-weight: 600;
padding: 1px 5px;
border-radius: var(--radius-pill);
vertical-align: middle;
}
.merge-ready { color: var(--accent-green); border: 1px solid var(--accent-green); }
.merge-conflicts { color: var(--accent-red); border: 1px solid var(--accent-red); }
/* Empty state */ /* Empty state */
.empty { .empty {
text-align: center; text-align: center;
@@ -510,17 +444,13 @@ a:active {
max-width: 960px; max-width: 960px;
} }
.card-grid, .card-grid {
#issue-list,
#pull-list {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-sm); gap: var(--spacing-sm);
} }
.card-grid .card, .card-grid .card {
#issue-list .card,
#pull-list .card {
margin-bottom: 0; margin-bottom: 0;
} }
} }