Move Gitea feed from frontend to backend with cached GraphQL proxy
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m39s

Replaces direct browser-to-Gitea API calls with a backend service that
proxies and caches the feed (1-min TTL), served via the existing GraphQL
HomeData query. Commit message parsing now happens server-side.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 00:14:59 +00:00
parent 5999eccc21
commit 7381cda7b8
11 changed files with 520 additions and 47 deletions

View File

@@ -66,6 +66,15 @@ type ComplexityRoot struct {
UpdatedAt func(childComplexity int) int
}
GiteaFeedItem struct {
AvatarURL func(childComplexity int) int
CommitMessage func(childComplexity int) int
CreatedAt func(childComplexity int) int
OpType func(childComplexity int) int
RepoName func(childComplexity int) int
RepoURL func(childComplexity int) int
}
Message struct {
AuthorID func(childComplexity int) int
Content func(childComplexity int) int
@@ -100,6 +109,7 @@ type ComplexityRoot struct {
Query struct {
Activities func(childComplexity int) int
Favorites func(childComplexity int) int
GiteaFeed func(childComplexity int) int
Me func(childComplexity int) int
Messages func(childComplexity int) int
Post func(childComplexity int, id int) int
@@ -197,6 +207,7 @@ type QueryResolver interface {
Messages(ctx context.Context) ([]*models.Message, error)
SpotifyListening(ctx context.Context) (*model.SpotifyPlaying, error)
SpotifyRecent(ctx context.Context) ([]*model.SpotifyRecentItem, error)
GiteaFeed(ctx context.Context) (*model.GiteaFeedItem, error)
Me(ctx context.Context) (*models.User, error)
}
type RowingResolver interface {
@@ -304,6 +315,43 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
return e.ComplexityRoot.Favorite.UpdatedAt(childComplexity), true
case "GiteaFeedItem.avatarUrl":
if e.ComplexityRoot.GiteaFeedItem.AvatarURL == nil {
break
}
return e.ComplexityRoot.GiteaFeedItem.AvatarURL(childComplexity), true
case "GiteaFeedItem.commitMessage":
if e.ComplexityRoot.GiteaFeedItem.CommitMessage == nil {
break
}
return e.ComplexityRoot.GiteaFeedItem.CommitMessage(childComplexity), true
case "GiteaFeedItem.createdAt":
if e.ComplexityRoot.GiteaFeedItem.CreatedAt == nil {
break
}
return e.ComplexityRoot.GiteaFeedItem.CreatedAt(childComplexity), true
case "GiteaFeedItem.opType":
if e.ComplexityRoot.GiteaFeedItem.OpType == nil {
break
}
return e.ComplexityRoot.GiteaFeedItem.OpType(childComplexity), true
case "GiteaFeedItem.repoName":
if e.ComplexityRoot.GiteaFeedItem.RepoName == nil {
break
}
return e.ComplexityRoot.GiteaFeedItem.RepoName(childComplexity), true
case "GiteaFeedItem.repoUrl":
if e.ComplexityRoot.GiteaFeedItem.RepoURL == nil {
break
}
return e.ComplexityRoot.GiteaFeedItem.RepoURL(childComplexity), true
case "Message.authorId":
if e.ComplexityRoot.Message.AuthorID == nil {
break
@@ -496,6 +544,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
}
return e.ComplexityRoot.Query.Favorites(childComplexity), true
case "Query.giteaFeed":
if e.ComplexityRoot.Query.GiteaFeed == nil {
break
}
return e.ComplexityRoot.Query.GiteaFeed(childComplexity), true
case "Query.me":
if e.ComplexityRoot.Query.Me == nil {
@@ -796,7 +850,7 @@ func newExecutionContext(
}
}
//go:embed "schema/activity.graphql" "schema/auth.graphql" "schema/favorite.graphql" "schema/message.graphql" "schema/post.graphql" "schema/rowing.graphql" "schema/schema.graphql" "schema/spotify.graphql" "schema/user.graphql"
//go:embed "schema/activity.graphql" "schema/auth.graphql" "schema/favorite.graphql" "schema/gitea.graphql" "schema/message.graphql" "schema/post.graphql" "schema/rowing.graphql" "schema/schema.graphql" "schema/spotify.graphql" "schema/user.graphql"
var sourcesFS embed.FS
func sourceData(filename string) string {
@@ -811,6 +865,7 @@ var sources = []*ast.Source{
{Name: "schema/activity.graphql", Input: sourceData("schema/activity.graphql"), BuiltIn: false},
{Name: "schema/auth.graphql", Input: sourceData("schema/auth.graphql"), BuiltIn: false},
{Name: "schema/favorite.graphql", Input: sourceData("schema/favorite.graphql"), BuiltIn: false},
{Name: "schema/gitea.graphql", Input: sourceData("schema/gitea.graphql"), BuiltIn: false},
{Name: "schema/message.graphql", Input: sourceData("schema/message.graphql"), BuiltIn: false},
{Name: "schema/post.graphql", Input: sourceData("schema/post.graphql"), BuiltIn: false},
{Name: "schema/rowing.graphql", Input: sourceData("schema/rowing.graphql"), BuiltIn: false},
@@ -1407,6 +1462,180 @@ func (ec *executionContext) fieldContext_Favorite_link(_ context.Context, field
return fc, nil
}
func (ec *executionContext) _GiteaFeedItem_avatarUrl(ctx context.Context, field graphql.CollectedField, obj *model.GiteaFeedItem) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_GiteaFeedItem_avatarUrl,
func(ctx context.Context) (any, error) {
return obj.AvatarURL, nil
},
nil,
ec.marshalNString2string,
true,
true,
)
}
func (ec *executionContext) fieldContext_GiteaFeedItem_avatarUrl(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "GiteaFeedItem",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type String does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _GiteaFeedItem_repoUrl(ctx context.Context, field graphql.CollectedField, obj *model.GiteaFeedItem) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_GiteaFeedItem_repoUrl,
func(ctx context.Context) (any, error) {
return obj.RepoURL, nil
},
nil,
ec.marshalNString2string,
true,
true,
)
}
func (ec *executionContext) fieldContext_GiteaFeedItem_repoUrl(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "GiteaFeedItem",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type String does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _GiteaFeedItem_repoName(ctx context.Context, field graphql.CollectedField, obj *model.GiteaFeedItem) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_GiteaFeedItem_repoName,
func(ctx context.Context) (any, error) {
return obj.RepoName, nil
},
nil,
ec.marshalNString2string,
true,
true,
)
}
func (ec *executionContext) fieldContext_GiteaFeedItem_repoName(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "GiteaFeedItem",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type String does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _GiteaFeedItem_opType(ctx context.Context, field graphql.CollectedField, obj *model.GiteaFeedItem) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_GiteaFeedItem_opType,
func(ctx context.Context) (any, error) {
return obj.OpType, nil
},
nil,
ec.marshalNString2string,
true,
true,
)
}
func (ec *executionContext) fieldContext_GiteaFeedItem_opType(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "GiteaFeedItem",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type String does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _GiteaFeedItem_commitMessage(ctx context.Context, field graphql.CollectedField, obj *model.GiteaFeedItem) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_GiteaFeedItem_commitMessage,
func(ctx context.Context) (any, error) {
return obj.CommitMessage, nil
},
nil,
ec.marshalNString2string,
true,
true,
)
}
func (ec *executionContext) fieldContext_GiteaFeedItem_commitMessage(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "GiteaFeedItem",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type String does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _GiteaFeedItem_createdAt(ctx context.Context, field graphql.CollectedField, obj *model.GiteaFeedItem) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_GiteaFeedItem_createdAt,
func(ctx context.Context) (any, error) {
return obj.CreatedAt, nil
},
nil,
ec.marshalNTime2timeᚐTime,
true,
true,
)
}
func (ec *executionContext) fieldContext_GiteaFeedItem_createdAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "GiteaFeedItem",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type Time does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _Message_id(ctx context.Context, field graphql.CollectedField, obj *models.Message) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
@@ -2713,6 +2942,49 @@ func (ec *executionContext) fieldContext_Query_spotifyRecent(_ context.Context,
return fc, nil
}
func (ec *executionContext) _Query_giteaFeed(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_Query_giteaFeed,
func(ctx context.Context) (any, error) {
return ec.Resolvers.Query().GiteaFeed(ctx)
},
nil,
ec.marshalOGiteaFeedItem2ᚖadamᚑfrenchᚗcoᚗukᚋbackendᚋgraphᚋmodelᚐGiteaFeedItem,
true,
false,
)
}
func (ec *executionContext) fieldContext_Query_giteaFeed(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "Query",
Field: field,
IsMethod: true,
IsResolver: true,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
switch field.Name {
case "avatarUrl":
return ec.fieldContext_GiteaFeedItem_avatarUrl(ctx, field)
case "repoUrl":
return ec.fieldContext_GiteaFeedItem_repoUrl(ctx, field)
case "repoName":
return ec.fieldContext_GiteaFeedItem_repoName(ctx, field)
case "opType":
return ec.fieldContext_GiteaFeedItem_opType(ctx, field)
case "commitMessage":
return ec.fieldContext_GiteaFeedItem_commitMessage(ctx, field)
case "createdAt":
return ec.fieldContext_GiteaFeedItem_createdAt(ctx, field)
}
return nil, fmt.Errorf("no field named %q was found under type GiteaFeedItem", field.Name)
},
}
return fc, nil
}
func (ec *executionContext) _Query_me(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
@@ -5472,6 +5744,70 @@ func (ec *executionContext) _Favorite(ctx context.Context, sel ast.SelectionSet,
return out
}
var giteaFeedItemImplementors = []string{"GiteaFeedItem"}
func (ec *executionContext) _GiteaFeedItem(ctx context.Context, sel ast.SelectionSet, obj *model.GiteaFeedItem) graphql.Marshaler {
fields := graphql.CollectFields(ec.OperationContext, sel, giteaFeedItemImplementors)
out := graphql.NewFieldSet(fields)
deferred := make(map[string]*graphql.FieldSet)
for i, field := range fields {
switch field.Name {
case "__typename":
out.Values[i] = graphql.MarshalString("GiteaFeedItem")
case "avatarUrl":
out.Values[i] = ec._GiteaFeedItem_avatarUrl(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "repoUrl":
out.Values[i] = ec._GiteaFeedItem_repoUrl(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "repoName":
out.Values[i] = ec._GiteaFeedItem_repoName(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "opType":
out.Values[i] = ec._GiteaFeedItem_opType(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "commitMessage":
out.Values[i] = ec._GiteaFeedItem_commitMessage(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "createdAt":
out.Values[i] = ec._GiteaFeedItem_createdAt(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
default:
panic("unknown field " + strconv.Quote(field.Name))
}
}
out.Dispatch(ctx)
if out.Invalids > 0 {
return graphql.Null
}
atomic.AddInt32(&ec.Deferred, int32(len(deferred)))
for label, dfs := range deferred {
ec.ProcessDeferredGroup(graphql.DeferredGroup{
Label: label,
Path: graphql.GetPath(ctx),
FieldSet: dfs,
Context: ctx,
})
}
return out
}
var messageImplementors = []string{"Message"}
func (ec *executionContext) _Message(ctx context.Context, sel ast.SelectionSet, obj *models.Message) graphql.Marshaler {
@@ -6030,6 +6366,25 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr
func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
}
out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) })
case "giteaFeed":
field := field
innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
}
}()
res = ec._Query_giteaFeed(ctx, field)
return res
}
rrm := func(ctx context.Context) graphql.Marshaler {
return ec.OperationContext.RootResolverMiddleware(ctx,
func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
}
out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) })
case "me":
field := field
@@ -7505,6 +7860,13 @@ func (ec *executionContext) marshalOBoolean2ᚖbool(ctx context.Context, sel ast
return res
}
func (ec *executionContext) marshalOGiteaFeedItem2ᚖadamᚑfrenchᚗcoᚗukᚋbackendᚋgraphᚋmodelᚐGiteaFeedItem(ctx context.Context, sel ast.SelectionSet, v *model.GiteaFeedItem) graphql.Marshaler {
if v == nil {
return graphql.Null
}
return ec._GiteaFeedItem(ctx, sel, v)
}
func (ec *executionContext) marshalOPost2ᚖadamᚑfrenchᚗcoᚗukᚋbackendᚋmodelsᚐPost(ctx context.Context, sel ast.SelectionSet, v *models.Post) graphql.Marshaler {
if v == nil {
return graphql.Null

View File

@@ -0,0 +1,17 @@
package graph
import (
"adam-french.co.uk/backend/graph/model"
"adam-french.co.uk/backend/services"
)
func mapGiteaFeed(feed *services.GiteaFeedResponse) *model.GiteaFeedItem {
return &model.GiteaFeedItem{
AvatarURL: feed.ActUser.AvatarURL,
RepoURL: feed.Repo.HTMLURL,
RepoName: feed.Repo.FullName,
OpType: feed.OpType,
CommitMessage: services.ParseCommitMessage(feed.Content),
CreatedAt: feed.Created,
}
}

View File

@@ -34,6 +34,15 @@ type CreateUserInput struct {
Password string `json:"password"`
}
type GiteaFeedItem struct {
AvatarURL string `json:"avatarUrl"`
RepoURL string `json:"repoUrl"`
RepoName string `json:"repoName"`
OpType string `json:"opType"`
CommitMessage string `json:"commitMessage"`
CreatedAt time.Time `json:"createdAt"`
}
type LoginInput struct {
Username string `json:"username"`
Password string `json:"password"`

View File

@@ -13,6 +13,7 @@ import (
"adam-french.co.uk/backend/graph/model"
"adam-french.co.uk/backend/models"
"adam-french.co.uk/backend/services"
spotify "github.com/zmb3/spotify/v2"
"golang.org/x/crypto/bcrypt"
)
@@ -429,6 +430,26 @@ func (r *queryResolver) SpotifyRecent(ctx context.Context) ([]*model.SpotifyRece
return mapRecentItems(played), nil
}
// GiteaFeed is the resolver for the giteaFeed field.
func (r *queryResolver) GiteaFeed(ctx context.Context) (*model.GiteaFeedItem, error) {
if r.Store.GiteaFeedFresh() {
return mapGiteaFeed(r.Store.GiteaFeed), nil
}
feed, err := services.FetchLatestFeed(r.Store.GiteaHost, r.Store.GiteaPort)
if err != nil {
return nil, err
}
if feed == nil {
return nil, nil
}
r.Store.GiteaFeed = feed
r.Store.GiteaFeedFetchedAt = time.Now()
return mapGiteaFeed(feed), nil
}
// Me is the resolver for the me field.
func (r *queryResolver) Me(ctx context.Context) (*models.User, error) {
userID, ok := UserIDFromCtx(ctx)
@@ -453,4 +474,3 @@ func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }

View File

@@ -0,0 +1,8 @@
type GiteaFeedItem {
avatarUrl: String!
repoUrl: String!
repoName: String!
opType: String!
commitMessage: String!
createdAt: Time!
}

View File

@@ -11,6 +11,7 @@ type Query {
messages: [Message!]!
spotifyListening: SpotifyPlaying
spotifyRecent: [SpotifyRecentItem!]!
giteaFeed: GiteaFeedItem
me: User
}

View File

@@ -20,4 +20,16 @@ type Store struct {
RecentSongs *[]spotify.RecentlyPlayedItem
RecentSongsFetchedAt time.Time
GiteaHost string
GiteaPort string
GiteaFeed *services.GiteaFeedResponse
GiteaFeedFetchedAt time.Time
}
func (s *Store) GiteaFeedFresh() bool {
if s.GiteaFeed == nil {
return false
}
return time.Since(s.GiteaFeedFetchedAt) < time.Minute
}

View File

@@ -71,7 +71,10 @@ func main() {
notesConfig := services.NotesConfig{Dir: notesDir}
notes := services.InitNotes(&notesConfig)
store := handlers.Store{DB: db, SpotifyAuth: spotifyAuth, SpotifyClient: spotifyClient, ClaudeClient: claudeClient, Auth: auth, Notes: notes}
giteaHost := os.Getenv("GITEA_HOST")
giteaPort := os.Getenv("GITEA_PORT")
store := handlers.Store{DB: db, SpotifyAuth: spotifyAuth, SpotifyClient: spotifyClient, ClaudeClient: claudeClient, Auth: auth, Notes: notes, GiteaHost: giteaHost, GiteaPort: giteaPort}
protected := r.Group("/", store.AuthMiddlewear)
admin := r.Group("/", store.AuthMiddlewear, store.AdminMiddleware)

72
backend/services/gitea.go Normal file
View File

@@ -0,0 +1,72 @@
package services
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type GiteaFeedCommit struct {
Message string `json:"Message"`
}
type GiteaFeedContent struct {
Commits []GiteaFeedCommit `json:"Commits"`
}
type GiteaFeedUser struct {
AvatarURL string `json:"avatar_url"`
}
type GiteaFeedRepo struct {
FullName string `json:"full_name"`
HTMLURL string `json:"html_url"`
}
type GiteaFeedResponse struct {
ActUser GiteaFeedUser `json:"act_user"`
Repo GiteaFeedRepo `json:"repo"`
OpType string `json:"op_type"`
Content string `json:"content"`
Created time.Time `json:"created"`
}
func FetchLatestFeed(host, port string) (*GiteaFeedResponse, error) {
url := fmt.Sprintf("http://%s:%s/api/v1/users/adamf/activities/feeds?limit=1", host, port)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var items []GiteaFeedResponse
if err := json.Unmarshal(body, &items); err != nil {
return nil, err
}
if len(items) == 0 {
return nil, nil
}
return &items[0], nil
}
func ParseCommitMessage(content string) string {
var c GiteaFeedContent
if err := json.Unmarshal([]byte(content), &c); err != nil {
return ""
}
if len(c.Commits) == 0 {
return ""
}
return c.Commits[0].Message
}