Compare commits

...

14 Commits

Author SHA1 Message Date
agent-company d350500486 feat: add MergePull() method to Gitea client
Build and Push / test (pull_request) Successful in 39s
Build and Push / build (pull_request) Has been skipped
Add MergePull() that calls POST /repos/{owner}/{repo}/pulls/{index}/merge
with the specified merge style (merge, rebase, rebase-merge, squash).
Defaults to "merge" if no style specified. Includes unit tests for
success, default style, and error cases.

This is a prerequisite for #177 (merge PR button in UI) and #206
(POST /pulls merge handler).

Closes leeworks-agents/gitea-mobile#187

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 17:09:56 +00:00
AI-Manager f802ad296c Merge pull request 'feat: redirect to /settings on expired Gitea API token (#192)' (#214) from feat/token-expired-redirect-192 into master
Build and Push / test (push) Successful in 44s
Build and Push / build (push) Failing after 26s
2026-04-20 17:08:47 +00:00
AI-Manager 9ac282ba17 Merge pull request 'feat: add structured request logging with request-id (#200)' (#213) from feat/structured-logging-200 into master
Build and Push / test (push) Successful in 32s
Build and Push / build (push) Failing after 26s
2026-04-20 17:08:38 +00:00
AI-Manager 2cc1f16bce Merge pull request 'feat: render label badge pills with Gitea hex colors (#193)' (#212) from feat/label-color-pills-193 into master
Build and Push / test (push) Successful in 32s
Build and Push / build (push) Failing after 21s
2026-04-20 17:08:21 +00:00
AI-Manager c6fdcc8a11 Merge pull request 'feat: implement graceful shutdown on SIGTERM/SIGINT (#201)' (#211) from feat/graceful-shutdown-201 into master
Build and Push / test (push) Successful in 34s
Build and Push / build (push) Failing after 14s
2026-04-20 17:08:14 +00:00
AI-Manager baf6977a33 Merge pull request 'feat: add GetChangedFiles() to Gitea client (#205)' (#208) from feat/get-changed-files-205 into master
Build and Push / test (push) Successful in 53s
Build and Push / build (push) Failing after 15s
2026-04-20 17:05:47 +00:00
AI-Manager f1e4b0c2ba Merge pull request 'chore: add go.sum and fix Dockerfile build (#203, #180)' (#209) from chore/add-go-sum-203 into master
Build and Push / test (push) Successful in 1m40s
Build and Push / build (push) Failing after 14s
2026-04-20 17:05:34 +00:00
AI-Manager fa63d56e43 Merge pull request 'fix: add pull_request trigger to CI workflow (#204)' (#207) from fix/ci-pull-request-trigger-204 into master
Build and Push / test (push) Successful in 2m42s
Build and Push / build (push) Failing after 13s
2026-04-20 17:05:20 +00:00
agent-company d8a590eb79 feat: redirect to /settings with error banner when Gitea API token is expired
Add isTokenError() helper that detects HTTP 401/403 responses from the
Gitea API, and redirectOnTokenError() that redirects to /settings with
an error=token_expired query parameter. Update Dashboard, ListIssues,
and ListPulls handlers to check for token errors. The settings page now
displays an error banner explaining the token needs to be refreshed.

Closes leeworks-agents/gitea-mobile#192

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:13:04 +00:00
agent-company 46d23bf565 feat: render label badge pills using actual Gitea label hex colors
Update all label spans in issue/PR list and detail templates to use
background-color with the actual hex color from Gitea, replacing the
previous text-color-only styling. Add label-pill CSS class with text
shadow for readability against colored backgrounds.

Closes leeworks-agents/gitea-mobile#193

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:11:39 +00:00
agent-company c7d68c742d feat: implement graceful shutdown on SIGTERM/SIGINT with request draining
Replace http.ListenAndServe with http.Server and signal handling to
support graceful shutdown. On SIGTERM or SIGINT, the server drains
in-flight requests with a 15-second timeout before exiting cleanly.
This prevents dropped connections during Kubernetes pod termination.

Closes leeworks-agents/gitea-mobile#201

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:07:29 +00:00
agent-company 674c75f5eb chore: add go.sum to version control and copy in Dockerfile
Add empty go.sum file (no external dependencies yet) and update the
Dockerfile build stage to COPY go.sum alongside go.mod for reproducible
module downloads. This ensures the Docker build does not fail when Go
requires go.sum to be present.

Closes leeworks-agents/gitea-mobile#203
Closes leeworks-agents/gitea-mobile#180

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:06:53 +00:00
agent-company 4e7b072f82 feat: add GetChangedFiles() method to Gitea client for PR file diffs
Add ChangedFile type and GetChangedFiles() method that calls the Gitea
API endpoint GET /repos/{owner}/{repo}/pulls/{index}/files to retrieve
the list of files changed in a pull request. This is a prerequisite for
displaying changed files in the PR detail view (#189).

Includes unit tests for success and error cases.

Closes leeworks-agents/gitea-mobile#205

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:06:10 +00:00
agent-company 02a108a58e fix: add pull_request trigger to CI workflow so tests gate all PRs
Build and Push / test (pull_request) Successful in 3m33s
Build and Push / build (pull_request) Has been skipped
The CI workflow previously only triggered on push to master, meaning PRs
were not tested before merge. Add pull_request trigger for the test job
while restricting the Docker build+push job to push events only.

Closes leeworks-agents/gitea-mobile#204

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:04:58 +00:00
13 changed files with 288 additions and 8 deletions
+4
View File
@@ -4,6 +4,9 @@ on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
test:
@@ -24,6 +27,7 @@ jobs:
build:
runs-on: ubuntu-latest
needs: test
if: gitea.event_name == 'push'
steps:
- uses: actions/checkout@v4
+1 -1
View File
@@ -1,7 +1,7 @@
# Stage 1: Build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod ./
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /gitea-mobile ./cmd/server
+32 -1
View File
@@ -1,10 +1,14 @@
package main
import (
"context"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/config"
giteaclient "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/gitea"
@@ -36,8 +40,35 @@ func main() {
handler = middleware.Auth(cfg.SessionSecret, cfg.GiteaToken)(handler)
handler = middleware.Logging()(handler)
srv := &http.Server{
Addr: cfg.ListenAddr,
Handler: handler,
}
// Channel to receive shutdown signals.
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
// Start server in a goroutine.
go func() {
slog.Info("server starting", "addr", cfg.ListenAddr, "gitea_url", cfg.GiteaURL)
if err := http.ListenAndServe(cfg.ListenAddr, handler); err != nil {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}()
// Block until a shutdown signal is received.
sig := <-quit
slog.Info("shutdown signal received, draining in-flight requests", "signal", sig.String())
// Give in-flight requests up to 15 seconds to complete.
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
slog.Error("server forced to shutdown", "error", err)
os.Exit(1)
}
slog.Info("server stopped gracefully")
}
View File
+61
View File
@@ -760,6 +760,33 @@ func (c *Client) GetPull(ctx context.Context, token, owner, repo string, index i
return &pr, nil
}
// ChangedFile represents a file changed in a pull request.
type ChangedFile struct {
Filename string `json:"filename"`
Status string `json:"status"` // "added", "modified", "removed", "renamed"
Additions int `json:"additions"`
Deletions int `json:"deletions"`
Changes int `json:"changes"`
PreviousFilename string `json:"previous_filename,omitempty"`
}
// GetChangedFiles fetches the list of files changed in a pull request.
func (c *Client) GetChangedFiles(ctx context.Context, token, owner, repo string, index int64) ([]ChangedFile, error) {
path := fmt.Sprintf("/repos/%s/%s/pulls/%d/files?limit=50", owner, repo, index)
resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("fetching changed files: %w", err)
}
defer resp.Body.Close()
var files []ChangedFile
if err := json.NewDecoder(resp.Body).Decode(&files); err != nil {
return nil, fmt.Errorf("decoding changed files: %w", err)
}
return files, nil
}
// GetIssueComments fetches comments for an issue or pull request.
func (c *Client) GetIssueComments(ctx context.Context, token, owner, repo string, index int64) ([]Comment, error) {
path := fmt.Sprintf("/repos/%s/%s/issues/%d/comments?limit=50", owner, repo, index)
@@ -945,6 +972,40 @@ func (c *Client) SetIssueState(ctx context.Context, token, owner, repo string, i
return nil
}
// MergePull merges a pull request using the specified merge style.
// Valid styles: "merge", "rebase", "rebase-merge", "squash".
// If style is empty, defaults to "merge".
func (c *Client) MergePull(ctx context.Context, token, owner, repo string, index int64, style, title, message string) error {
if style == "" {
style = "merge"
}
payload := map[string]string{
"Do": style,
}
if title != "" {
payload["merge_message_field"] = title
}
if message != "" {
payload["merge_message_field"] = message
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshaling merge request: %w", err)
}
path := fmt.Sprintf("/repos/%s/%s/pulls/%d/merge", owner, repo, index)
resp, err := c.doRequest(ctx, token, http.MethodPost, path, strings.NewReader(string(jsonData)))
if err != nil {
return fmt.Errorf("merging pull request: %w", err)
}
resp.Body.Close()
c.InvalidateAll()
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)
+138
View File
@@ -1456,3 +1456,141 @@ func TestRetryDelay_ExponentialBackoff(t *testing.T) {
}
}
}
func TestGetChangedFiles(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("expected GET, got %s", r.Method)
}
if r.URL.Path != "/api/v1/repos/owner1/repo1/pulls/5/files" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if r.Header.Get("Authorization") != "token test-token" {
t.Error("missing or wrong Authorization header")
}
files := []ChangedFile{
{Filename: "main.go", Status: "modified", Additions: 10, Deletions: 3, Changes: 13},
{Filename: "new_file.go", Status: "added", Additions: 25, Deletions: 0, Changes: 25},
{Filename: "old_file.go", Status: "removed", Additions: 0, Deletions: 15, Changes: 15},
}
json.NewEncoder(w).Encode(files)
}))
defer server.Close()
c := NewClient(server.URL)
files, err := c.GetChangedFiles(context.Background(), "test-token", "owner1", "repo1", 5)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(files) != 3 {
t.Fatalf("got %d files, want 3", len(files))
}
if files[0].Filename != "main.go" {
t.Errorf("files[0].Filename = %q, want %q", files[0].Filename, "main.go")
}
if files[0].Status != "modified" {
t.Errorf("files[0].Status = %q, want %q", files[0].Status, "modified")
}
if files[1].Status != "added" {
t.Errorf("files[1].Status = %q, want %q", files[1].Status, "added")
}
if files[2].Status != "removed" {
t.Errorf("files[2].Status = %q, want %q", files[2].Status, "removed")
}
}
func TestGetChangedFiles_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintln(w, `{"message":"pull request not found"}`)
}))
defer server.Close()
c := NewClient(server.URL)
_, err := c.GetChangedFiles(context.Background(), "test-token", "owner1", "repo1", 999)
if err == nil {
t.Fatal("expected error for 404 response, got nil")
}
if !strings.Contains(err.Error(), "404") {
t.Errorf("error should contain status code 404, got: %v", err)
}
}
func TestMergePull(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/api/v1/repos/owner1/repo1/pulls/5/merge" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if r.Header.Get("Authorization") != "token test-token" {
t.Error("missing or wrong Authorization header")
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode body: %v", err)
}
if body["Do"] != "squash" {
t.Errorf("expected Do=squash, got %q", body["Do"])
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("pulls-org1", "should-be-invalidated")
err := c.MergePull(context.Background(), "test-token", "owner1", "repo1", 5, "squash", "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify cache was invalidated.
_, ok := c.getFromCache("pulls-org1")
if ok {
t.Error("expected cache to be invalidated after MergePull")
}
}
func TestMergePull_DefaultStyle(t *testing.T) {
var receivedStyle string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode body: %v", err)
}
receivedStyle = body["Do"]
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
c := NewClient(server.URL)
err := c.MergePull(context.Background(), "test-token", "owner1", "repo1", 5, "", "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if receivedStyle != "merge" {
t.Errorf("expected default style 'merge', got %q", receivedStyle)
}
}
func TestMergePull_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusMethodNotAllowed)
fmt.Fprintln(w, `{"message":"not mergeable"}`)
}))
defer server.Close()
c := NewClient(server.URL)
err := c.MergePull(context.Background(), "test-token", "owner1", "repo1", 5, "merge", "", "")
if err == nil {
t.Fatal("expected error for 405 response, got nil")
}
if !strings.Contains(err.Error(), "405") {
t.Errorf("error should contain status code 405, got: %v", err)
}
}
+34
View File
@@ -78,6 +78,31 @@ func getToken(r *http.Request) string {
return middleware.TokenFromContext(r.Context())
}
// isTokenError returns true if the error indicates an expired or revoked API token.
func isTokenError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "API error 401") || strings.Contains(msg, "API error 403")
}
// redirectOnTokenError checks if the error is a token auth error and redirects
// to /settings with an error banner. Returns true if a redirect was performed.
func redirectOnTokenError(w http.ResponseWriter, r *http.Request, err error) bool {
if !isTokenError(err) {
return false
}
slog.Warn("Gitea API token expired or revoked, redirecting to settings", "error", err)
if isHTMX(r) {
w.Header().Set("HX-Redirect", "/settings?error=token_expired")
w.WriteHeader(http.StatusOK)
} else {
http.Redirect(w, r, "/settings?error=token_expired", http.StatusSeeOther)
}
return true
}
// getUserOrgs returns the list of org names the user belongs to.
func (h *Handler) getUserOrgs(r *http.Request) []string {
token := getToken(r)
@@ -263,6 +288,9 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
queue, err := h.Client.GetTriageQueue(r.Context(), token, queryOrgs)
if err != nil {
if redirectOnTokenError(w, r, err) {
return
}
slog.Error("failed to get triage queue", "error", err)
data.Error = "Error loading triage queue."
} else {
@@ -346,6 +374,9 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
result, err := h.Client.ListAllIssues(r.Context(), token, queryOrgs, selectedState, page, selectedLabel, selectedRepo)
if err != nil {
if redirectOnTokenError(w, r, err) {
return
}
slog.Error("failed to list issues", "error", err)
data.Error = "Error loading issues."
} else {
@@ -451,6 +482,9 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
result, err := h.Client.ListAllPullRequests(r.Context(), token, queryOrgs, selectedState, page, selectedLabel, selectedRepo)
if err != nil {
if redirectOnTokenError(w, r, err) {
return
}
slog.Error("failed to list pull requests", "error", err)
data.Error = "Error loading pull requests."
} else {
+7
View File
@@ -45,6 +45,13 @@ func (h *SettingsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
}
data := settingsData{HasToken: hasToken}
// Show error banner when redirected due to expired/revoked token.
if r.URL.Query().Get("error") == "token_expired" {
data.Message = "Your Gitea API token is expired or has been revoked. Please enter a new token."
data.MessageType = "error"
}
h.renderSettings(w, data)
}
+1 -1
View File
@@ -14,7 +14,7 @@
</span>
<span>{{.Issue.RepoOwner}}/{{.Issue.RepoName}} #{{.Issue.Number}}</span>
{{range .Issue.Labels}}
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
<span class="label label-pill" style="background-color:#{{.Color}};color:#fff;border:1px solid #{{.Color}}">{{.Name}}</span>
{{end}}
</div>
{{if .RenderedBody}}
+1 -1
View File
@@ -5,7 +5,7 @@
<div class="card-meta">
<span>{{.RepoOwner}}/{{.RepoName}} #{{.Number}}</span>
{{range .Labels}}
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
<span class="label label-pill" style="background-color:#{{.Color}};color:#fff;border:1px solid #{{.Color}}">{{.Name}}</span>
{{end}}
{{if .Assignee}}
<img src="{{.Assignee.AvatarURL}}" alt="{{.Assignee.Login}}" class="avatar" title="Assigned to {{.Assignee.Login}}">
+1 -1
View File
@@ -7,7 +7,7 @@
{{if eq .Pull.State "closed"}}<span class="state-closed">{{.Pull.State}}</span>{{else}}<span class="state-open">{{.Pull.State}}</span>{{end}}
<span>{{.Pull.RepoOwner}}/{{.Pull.RepoName}} #{{.Pull.Number}}</span>
{{range .Pull.Labels}}
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
<span class="label label-pill" style="background-color:#{{.Color}};color:#fff;border:1px solid #{{.Color}}">{{.Name}}</span>
{{end}}
</div>
<div class="card-meta" style="margin-top:0.5rem;">
+1 -1
View File
@@ -8,7 +8,7 @@
<div class="card-meta">
<span>{{.RepoOwner}}/{{.RepoName}} #{{.Number}}</span>
{{range .Labels}}
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
<span class="label label-pill" style="background-color:#{{.Color}};color:#fff;border:1px solid #{{.Color}}">{{.Name}}</span>
{{end}}
<span class="diff-add">+{{.Additions}}</span>
<span class="diff-del">-{{.Deletions}}</span>
+5
View File
@@ -176,6 +176,11 @@ a:active {
white-space: nowrap;
}
.label-pill {
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
font-weight: 600;
}
.type-badge {
font-size: 0.65rem;
text-transform: uppercase;