Compare commits

..

1 Commits

Author SHA1 Message Date
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
4 changed files with 41 additions and 112 deletions
-34
View File
@@ -945,40 +945,6 @@ 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)
-78
View File
@@ -1456,81 +1456,3 @@ func TestRetryDelay_ExponentialBackoff(t *testing.T) {
}
}
}
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)
}