Skip to content

Commit 3eaab98

Browse files
6543Stelios Malathouras
authored and
Stelios Malathouras
committed
Add RSS/Atom feed support for user actions (go-gitea#16002)
Return rss/atom feed for user based on rss url suffix or Content-Type header.
1 parent 4a715f5 commit 3eaab98

22 files changed

+1521
-39
lines changed

go.mod

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ require (
5757
github.com/google/go-querystring v1.1.0 // indirect
5858
github.com/google/uuid v1.2.0
5959
github.com/gorilla/context v1.1.1
60+
github.com/gorilla/feeds v1.1.1
6061
github.com/gorilla/mux v1.8.0 // indirect
6162
github.com/gorilla/sessions v1.2.1 // indirect
6263
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect

go.sum

+2
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,8 @@ github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8
598598
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
599599
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
600600
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
601+
github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY=
602+
github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
601603
github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
602604
github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
603605
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=

modules/context/context.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ func (ctx *Context) PlainText(status int, bs []byte) {
320320
ctx.Resp.WriteHeader(status)
321321
ctx.Resp.Header().Set("Content-Type", "text/plain;charset=utf-8")
322322
if _, err := ctx.Resp.Write(bs); err != nil {
323-
ctx.ServerError("Render JSON failed", err)
323+
ctx.ServerError("Write bytes failed", err)
324324
}
325325
}
326326

options/locale/locale_en-US.ini

+3
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ view_home = View %s
228228
search_repos = Find a repository…
229229
filter = Other Filters
230230
filter_by_team_repositories = Filter by team repositories
231+
feed_of = Feed of "%s"
231232
232233
show_archived = Archived
233234
show_both_archived_unarchived = Showing both archived and unarchived
@@ -2777,6 +2778,8 @@ publish_release = `released <a href="%s/releases/tag/%s"> "%[4]s" </a> at <a hr
27772778
review_dismissed = `dismissed review from <b>%[4]s</b> for <a href="%[1]s/pulls/%[2]s">%[3]s#%[2]s</a>`
27782779
review_dismissed_reason = Reason:
27792780
create_branch = created branch <a href="%[1]s/src/branch/%[2]s">%[3]s</a> in <a href="%[1]s">%[4]s</a>
2781+
stared_repo = stared <a href="%[1]s">%[2]s</a>
2782+
watched_repo = started watching <a href="%[1]s">%[2]s</a>
27802783

27812784
[tool]
27822785
ago = %s ago

routers/web/feed/convert.go

+154
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Copyright 2021 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package feed
6+
7+
import (
8+
"fmt"
9+
"html"
10+
"net/url"
11+
"strconv"
12+
"strings"
13+
14+
"code.gitea.io/gitea/models"
15+
"code.gitea.io/gitea/modules/context"
16+
"code.gitea.io/gitea/modules/setting"
17+
"code.gitea.io/gitea/modules/templates"
18+
19+
"github.com/gorilla/feeds"
20+
)
21+
22+
// feedActionsToFeedItems convert gitea's Action feed to feeds Item
23+
func feedActionsToFeedItems(ctx *context.Context, actions []*models.Action) (items []*feeds.Item, err error) {
24+
for _, act := range actions {
25+
act.LoadActUser()
26+
27+
content, desc, title := "", "", ""
28+
29+
link := &feeds.Link{Href: act.GetCommentLink()}
30+
31+
// title
32+
title = act.ActUser.DisplayName() + " "
33+
switch act.OpType {
34+
case models.ActionCreateRepo:
35+
title += ctx.Tr("action.create_repo", act.GetRepoLink(), act.ShortRepoPath())
36+
case models.ActionRenameRepo:
37+
title += ctx.Tr("action.rename_repo", act.GetContent(), act.GetRepoLink(), act.ShortRepoPath())
38+
case models.ActionCommitRepo:
39+
branchLink := act.GetBranch()
40+
if len(act.Content) != 0 {
41+
title += ctx.Tr("action.commit_repo", act.GetRepoLink(), branchLink, act.GetBranch(), act.ShortRepoPath())
42+
} else {
43+
title += ctx.Tr("action.create_branch", act.GetRepoLink(), branchLink, act.GetBranch(), act.ShortRepoPath())
44+
}
45+
case models.ActionCreateIssue:
46+
title += ctx.Tr("action.create_issue", act.GetRepoLink(), act.GetIssueInfos()[0], act.ShortRepoPath())
47+
case models.ActionCreatePullRequest:
48+
title += ctx.Tr("action.create_pull_request", act.GetRepoLink(), act.GetIssueInfos()[0], act.ShortRepoPath())
49+
case models.ActionTransferRepo:
50+
title += ctx.Tr("action.transfer_repo", act.GetContent(), act.GetRepoLink(), act.ShortRepoPath())
51+
case models.ActionPushTag:
52+
title += ctx.Tr("action.push_tag", act.GetRepoLink(), url.QueryEscape(act.GetTag()), act.ShortRepoPath())
53+
case models.ActionCommentIssue:
54+
title += ctx.Tr("action.comment_issue", act.GetRepoLink(), act.GetIssueInfos()[0], act.ShortRepoPath())
55+
case models.ActionMergePullRequest:
56+
title += ctx.Tr("action.merge_pull_request", act.GetRepoLink(), act.GetIssueInfos()[0], act.ShortRepoPath())
57+
case models.ActionCloseIssue:
58+
title += ctx.Tr("action.close_issue", act.GetRepoLink(), act.GetIssueInfos()[0], act.ShortRepoPath())
59+
case models.ActionReopenIssue:
60+
title += ctx.Tr("action.reopen_issue", act.GetRepoLink(), act.GetIssueInfos()[0], act.ShortRepoPath())
61+
case models.ActionClosePullRequest:
62+
title += ctx.Tr("action.close_pull_request", act.GetRepoLink(), act.GetIssueInfos()[0], act.ShortRepoPath())
63+
case models.ActionReopenPullRequest:
64+
title += ctx.Tr("action.reopen_pull_request", act.GetRepoLink(), act.GetIssueInfos()[0], act.ShortRepoPath)
65+
case models.ActionDeleteTag:
66+
title += ctx.Tr("action.delete_tag", act.GetRepoLink(), html.EscapeString(act.GetTag()), act.ShortRepoPath())
67+
case models.ActionDeleteBranch:
68+
title += ctx.Tr("action.delete_branch", act.GetRepoLink(), html.EscapeString(act.GetBranch()), act.ShortRepoPath())
69+
case models.ActionMirrorSyncPush:
70+
title += ctx.Tr("action.mirror_sync_push", act.GetRepoLink(), url.QueryEscape(act.GetBranch()), html.EscapeString(act.GetBranch()), act.ShortRepoPath())
71+
case models.ActionMirrorSyncCreate:
72+
title += ctx.Tr("action.mirror_sync_create", act.GetRepoLink(), html.EscapeString(act.GetBranch()), act.ShortRepoPath())
73+
case models.ActionMirrorSyncDelete:
74+
title += ctx.Tr("action.mirror_sync_delete", act.GetRepoLink(), html.EscapeString(act.GetBranch()), act.ShortRepoPath())
75+
case models.ActionApprovePullRequest:
76+
title += ctx.Tr("action.approve_pull_request", act.GetRepoLink(), act.GetIssueInfos()[0], act.ShortRepoPath())
77+
case models.ActionRejectPullRequest:
78+
title += ctx.Tr("action.reject_pull_request", act.GetRepoLink(), act.GetIssueInfos()[0], act.ShortRepoPath())
79+
case models.ActionCommentPull:
80+
title += ctx.Tr("action.comment_pull", act.GetRepoLink(), act.GetIssueInfos()[0], act.ShortRepoPath())
81+
case models.ActionPublishRelease:
82+
title += ctx.Tr("action.publish_release", act.GetRepoLink(), html.EscapeString(act.GetBranch()), act.ShortRepoPath(), act.Content)
83+
case models.ActionPullReviewDismissed:
84+
title += ctx.Tr("action.review_dismissed", act.GetRepoLink(), act.GetIssueInfos()[0], act.ShortRepoPath(), act.GetIssueInfos()[1])
85+
case models.ActionStarRepo:
86+
title += ctx.Tr("action.stared_repo", act.GetRepoLink(), act.GetRepoPath())
87+
link = &feeds.Link{Href: act.GetRepoLink()}
88+
case models.ActionWatchRepo:
89+
title += ctx.Tr("action.watched_repo", act.GetRepoLink(), act.GetRepoPath())
90+
link = &feeds.Link{Href: act.GetRepoLink()}
91+
default:
92+
return nil, fmt.Errorf("unknown action type: %v", act.OpType)
93+
}
94+
95+
// description & content
96+
{
97+
switch act.OpType {
98+
case models.ActionCommitRepo, models.ActionMirrorSyncPush:
99+
push := templates.ActionContent2Commits(act)
100+
repoLink := act.GetRepoLink()
101+
102+
for _, commit := range push.Commits {
103+
if len(desc) != 0 {
104+
desc += "\n\n"
105+
}
106+
desc += fmt.Sprintf("<a href=\"%s\">%s</a>\n%s",
107+
fmt.Sprintf("%s/commit/%s", act.GetRepoLink(), commit.Sha1),
108+
commit.Sha1,
109+
templates.RenderCommitMessage(commit.Message, repoLink, nil),
110+
)
111+
}
112+
113+
if push.Len > 1 {
114+
link = &feeds.Link{Href: fmt.Sprintf("%s/%s", setting.AppSubURL, push.CompareURL)}
115+
} else if push.Len == 1 {
116+
link = &feeds.Link{Href: fmt.Sprintf("%s/commit/%s", act.GetRepoLink(), push.Commits[0].Sha1)}
117+
}
118+
119+
case models.ActionCreateIssue, models.ActionCreatePullRequest:
120+
desc = strings.Join(act.GetIssueInfos(), "#")
121+
content = act.GetIssueContent()
122+
case models.ActionCommentIssue, models.ActionApprovePullRequest, models.ActionRejectPullRequest, models.ActionCommentPull:
123+
desc = act.GetIssueTitle()
124+
comment := act.GetIssueInfos()[1]
125+
if len(comment) != 0 {
126+
desc += "\n\n" + comment
127+
}
128+
case models.ActionMergePullRequest:
129+
desc = act.GetIssueInfos()[1]
130+
case models.ActionCloseIssue, models.ActionReopenIssue, models.ActionClosePullRequest, models.ActionReopenPullRequest:
131+
desc = act.GetIssueTitle()
132+
case models.ActionPullReviewDismissed:
133+
desc = ctx.Tr("action.review_dismissed_reason") + "\n\n" + act.GetIssueInfos()[2]
134+
}
135+
}
136+
if len(content) == 0 {
137+
content = desc
138+
}
139+
140+
items = append(items, &feeds.Item{
141+
Title: title,
142+
Link: link,
143+
Description: desc,
144+
Author: &feeds.Author{
145+
Name: act.ActUser.DisplayName(),
146+
Email: act.ActUser.GetEmail(),
147+
},
148+
Id: strconv.FormatInt(act.ID, 10),
149+
Created: act.CreatedUnix.AsTime(),
150+
Content: content,
151+
})
152+
}
153+
return
154+
}

routers/web/feed/profile.go

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright 2021 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package feed
6+
7+
import (
8+
"net/http"
9+
"time"
10+
11+
"code.gitea.io/gitea/models"
12+
"code.gitea.io/gitea/modules/context"
13+
14+
"github.com/gorilla/feeds"
15+
)
16+
17+
// RetrieveFeeds loads feeds for the specified user
18+
func RetrieveFeeds(ctx *context.Context, options models.GetFeedsOptions) []*models.Action {
19+
actions, err := models.GetFeeds(options)
20+
if err != nil {
21+
ctx.ServerError("GetFeeds", err)
22+
return nil
23+
}
24+
25+
userCache := map[int64]*models.User{options.RequestedUser.ID: options.RequestedUser}
26+
if ctx.User != nil {
27+
userCache[ctx.User.ID] = ctx.User
28+
}
29+
for _, act := range actions {
30+
if act.ActUser != nil {
31+
userCache[act.ActUserID] = act.ActUser
32+
}
33+
}
34+
35+
for _, act := range actions {
36+
repoOwner, ok := userCache[act.Repo.OwnerID]
37+
if !ok {
38+
repoOwner, err = models.GetUserByID(act.Repo.OwnerID)
39+
if err != nil {
40+
if models.IsErrUserNotExist(err) {
41+
continue
42+
}
43+
ctx.ServerError("GetUserByID", err)
44+
return nil
45+
}
46+
userCache[repoOwner.ID] = repoOwner
47+
}
48+
act.Repo.Owner = repoOwner
49+
}
50+
return actions
51+
}
52+
53+
// ShowUserFeed show user activity as RSS / Atom feed
54+
func ShowUserFeed(ctx *context.Context, ctxUser *models.User, formatType string) {
55+
actions := RetrieveFeeds(ctx, models.GetFeedsOptions{
56+
RequestedUser: ctxUser,
57+
Actor: ctx.User,
58+
IncludePrivate: false,
59+
OnlyPerformedBy: true,
60+
IncludeDeleted: false,
61+
Date: ctx.FormString("date"),
62+
})
63+
if ctx.Written() {
64+
return
65+
}
66+
67+
feed := &feeds.Feed{
68+
Title: ctx.Tr("home.feed_of", ctxUser.DisplayName()),
69+
Link: &feeds.Link{Href: ctxUser.HTMLURL()},
70+
Description: ctxUser.Description,
71+
Created: time.Now(),
72+
}
73+
74+
var err error
75+
feed.Items, err = feedActionsToFeedItems(ctx, actions)
76+
if err != nil {
77+
ctx.ServerError("convert feed", err)
78+
return
79+
}
80+
81+
writeFeed(ctx, feed, formatType)
82+
}
83+
84+
// writeFeed write a feeds.Feed as atom or rss to ctx.Resp
85+
func writeFeed(ctx *context.Context, feed *feeds.Feed, formatType string) {
86+
ctx.Resp.WriteHeader(http.StatusOK)
87+
if formatType == "atom" {
88+
ctx.Resp.Header().Set("Content-Type", "application/atom+xml;charset=utf-8")
89+
if err := feed.WriteAtom(ctx.Resp); err != nil {
90+
ctx.ServerError("Render Atom failed", err)
91+
}
92+
} else {
93+
ctx.Resp.Header().Set("Content-Type", "application/rss+xml;charset=utf-8")
94+
if err := feed.WriteRss(ctx.Resp); err != nil {
95+
ctx.ServerError("Render RSS failed", err)
96+
}
97+
}
98+
}

routers/web/user/home.go

+3-37
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"code.gitea.io/gitea/modules/markup/markdown"
2626
"code.gitea.io/gitea/modules/setting"
2727
"code.gitea.io/gitea/modules/util"
28+
"code.gitea.io/gitea/routers/web/feed"
2829
issue_service "code.gitea.io/gitea/services/issue"
2930
pull_service "code.gitea.io/gitea/services/pull"
3031

@@ -60,42 +61,6 @@ func getDashboardContextUser(ctx *context.Context) *models.User {
6061
return ctxUser
6162
}
6263

63-
// retrieveFeeds loads feeds for the specified user
64-
func retrieveFeeds(ctx *context.Context, options models.GetFeedsOptions) {
65-
actions, err := models.GetFeeds(options)
66-
if err != nil {
67-
ctx.ServerError("GetFeeds", err)
68-
return
69-
}
70-
71-
userCache := map[int64]*models.User{options.RequestedUser.ID: options.RequestedUser}
72-
if ctx.User != nil {
73-
userCache[ctx.User.ID] = ctx.User
74-
}
75-
for _, act := range actions {
76-
if act.ActUser != nil {
77-
userCache[act.ActUserID] = act.ActUser
78-
}
79-
}
80-
81-
for _, act := range actions {
82-
repoOwner, ok := userCache[act.Repo.OwnerID]
83-
if !ok {
84-
repoOwner, err = models.GetUserByID(act.Repo.OwnerID)
85-
if err != nil {
86-
if models.IsErrUserNotExist(err) {
87-
continue
88-
}
89-
ctx.ServerError("GetUserByID", err)
90-
return
91-
}
92-
userCache[repoOwner.ID] = repoOwner
93-
}
94-
act.Repo.Owner = repoOwner
95-
}
96-
ctx.Data["Feeds"] = actions
97-
}
98-
9964
// Dashboard render the dashboard page
10065
func Dashboard(ctx *context.Context) {
10166
ctxUser := getDashboardContextUser(ctx)
@@ -154,7 +119,7 @@ func Dashboard(ctx *context.Context) {
154119
ctx.Data["MirrorCount"] = len(mirrors)
155120
ctx.Data["Mirrors"] = mirrors
156121

157-
retrieveFeeds(ctx, models.GetFeedsOptions{
122+
ctx.Data["Feeds"] = feed.RetrieveFeeds(ctx, models.GetFeedsOptions{
158123
RequestedUser: ctxUser,
159124
RequestedTeam: ctx.Org.Team,
160125
Actor: ctx.User,
@@ -167,6 +132,7 @@ func Dashboard(ctx *context.Context) {
167132
if ctx.Written() {
168133
return
169134
}
135+
170136
ctx.HTML(http.StatusOK, tplDashboard)
171137
}
172138

0 commit comments

Comments
 (0)