From 6b0df6d8da76d77a9b5c42dcfa78dbfe197fd56d Mon Sep 17 00:00:00 2001 From: Zettat123 Date: Tue, 4 Apr 2023 21:35:31 +0800 Subject: [PATCH] Add activity feeds API (#23494) Close #5666 Add APIs for getting activity feeds. --- models/activities/action.go | 61 +++++++ modules/structs/activity.go | 22 +++ routers/api/v1/api.go | 5 + routers/api/v1/org/org.go | 67 +++++++ routers/api/v1/org/team.go | 53 ++++++ routers/api/v1/repo/repo.go | 57 ++++++ routers/api/v1/swagger/activity.go | 15 ++ routers/api/v1/user/user.go | 57 ++++++ services/convert/activity.go | 52 ++++++ templates/swagger/v1_json.tmpl | 276 +++++++++++++++++++++++++++++ 10 files changed, 665 insertions(+) create mode 100644 modules/structs/activity.go create mode 100644 routers/api/v1/swagger/activity.go create mode 100644 services/convert/activity.go diff --git a/models/activities/action.go b/models/activities/action.go index 4111d8098b8e..f75ab559823d 100644 --- a/models/activities/action.go +++ b/models/activities/action.go @@ -66,6 +66,67 @@ const ( ActionAutoMergePullRequest // 27 ) +func (at ActionType) String() string { + switch at { + case ActionCreateRepo: + return "create_repo" + case ActionRenameRepo: + return "rename_repo" + case ActionStarRepo: + return "star_repo" + case ActionWatchRepo: + return "watch_repo" + case ActionCommitRepo: + return "commit_repo" + case ActionCreateIssue: + return "create_issue" + case ActionCreatePullRequest: + return "create_pull_request" + case ActionTransferRepo: + return "transfer_repo" + case ActionPushTag: + return "push_tag" + case ActionCommentIssue: + return "comment_issue" + case ActionMergePullRequest: + return "merge_pull_request" + case ActionCloseIssue: + return "close_issue" + case ActionReopenIssue: + return "reopen_issue" + case ActionClosePullRequest: + return "close_pull_request" + case ActionReopenPullRequest: + return "reopen_pull_request" + case ActionDeleteTag: + return "delete_tag" + case ActionDeleteBranch: + return "delete_branch" + case ActionMirrorSyncPush: + return "mirror_sync_push" + case ActionMirrorSyncCreate: + return "mirror_sync_create" + case ActionMirrorSyncDelete: + return "mirror_sync_delete" + case ActionApprovePullRequest: + return "approve_pull_request" + case ActionRejectPullRequest: + return "reject_pull_request" + case ActionCommentPull: + return "comment_pull" + case ActionPublishRelease: + return "publish_release" + case ActionPullReviewDismissed: + return "pull_review_dismissed" + case ActionPullRequestReadyForReview: + return "pull_request_ready_for_review" + case ActionAutoMergePullRequest: + return "auto_merge_pull_request" + default: + return "action-" + strconv.Itoa(int(at)) + } +} + // Action represents user operation type and other information to // repository. It implemented interface base.Actioner so that can be // used in template render. diff --git a/modules/structs/activity.go b/modules/structs/activity.go new file mode 100644 index 000000000000..6d2ee56b0857 --- /dev/null +++ b/modules/structs/activity.go @@ -0,0 +1,22 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +import "time" + +type Activity struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` // Receiver user + OpType string `json:"op_type"` + ActUserID int64 `json:"act_user_id"` + ActUser *User `json:"act_user"` + RepoID int64 `json:"repo_id"` + Repo *Repository `json:"repo"` + CommentID int64 `json:"comment_id"` + Comment *Comment `json:"comment"` + RefName string `json:"ref_name"` + IsPrivate bool `json:"is_private"` + Content string `json:"content"` + Created time.Time `json:"created"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 21797bd1a0aa..9510b17e2df0 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -754,6 +754,8 @@ func Routes(ctx gocontext.Context) *web.Route { Post(bind(api.CreateAccessTokenOption{}), user.CreateAccessToken) m.Combo("/{id}").Delete(user.DeleteAccessToken) }, reqBasicAuth()) + + m.Get("/activities/feeds", user.ListUserActivityFeeds) }, context_service.UserAssignmentAPI()) }) @@ -1177,6 +1179,7 @@ func Routes(ctx gocontext.Context) *web.Route { m.Get("/issue_config", context.ReferencesGitRepo(), repo.GetIssueConfig) m.Get("/issue_config/validate", context.ReferencesGitRepo(), repo.ValidateIssueConfig) m.Get("/languages", reqRepoReader(unit.TypeCode), repo.GetLanguages) + m.Get("/activities/feeds", repo.ListRepoActivityFeeds) }, repoAssignment()) }) @@ -1234,6 +1237,7 @@ func Routes(ctx gocontext.Context) *web.Route { Patch(bind(api.EditHookOption{}), org.EditHook). Delete(org.DeleteHook) }, reqToken(auth_model.AccessTokenScopeAdminOrgHook), reqOrgOwnership(), reqWebhooksEnabled()) + m.Get("/activities/feeds", org.ListOrgActivityFeeds) }, orgAssignment(true)) m.Group("/teams/{teamid}", func() { m.Combo("").Get(reqToken(auth_model.AccessTokenScopeReadOrg), org.GetTeam). @@ -1253,6 +1257,7 @@ func Routes(ctx gocontext.Context) *web.Route { Delete(reqToken(auth_model.AccessTokenScopeWriteOrg), org.RemoveTeamRepository). Get(reqToken(auth_model.AccessTokenScopeReadOrg), org.GetTeamRepo) }) + m.Get("/activities/feeds", org.ListTeamActivityFeeds) }, orgAssignment(false, true), reqToken(""), reqTeamMembership()) m.Group("/admin", func() { diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index 75420dcc43a8..4e30ad17620f 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -7,6 +7,7 @@ package org import ( "net/http" + activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" @@ -370,3 +371,69 @@ func Delete(ctx *context.APIContext) { } ctx.Status(http.StatusNoContent) } + +func ListOrgActivityFeeds(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/activities/feeds organization orgListActivityFeeds + // --- + // summary: List an organization's activity feeds + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the org + // type: string + // required: true + // - name: date + // in: query + // description: the date of the activities to be found + // type: string + // format: date + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/ActivityFeedsList" + // "404": + // "$ref": "#/responses/notFound" + + includePrivate := false + if ctx.IsSigned { + if ctx.Doer.IsAdmin { + includePrivate = true + } else { + org := organization.OrgFromUser(ctx.ContextUser) + isMember, err := org.IsOrgMember(ctx.Doer.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "IsOrgMember", err) + return + } + includePrivate = isMember + } + } + + listOptions := utils.GetListOptions(ctx) + + opts := activities_model.GetFeedsOptions{ + RequestedUser: ctx.ContextUser, + Actor: ctx.Doer, + IncludePrivate: includePrivate, + Date: ctx.FormString("date"), + ListOptions: listOptions, + } + + feeds, count, err := activities_model.GetFeeds(ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetFeeds", err) + return + } + ctx.SetTotalCountHeader(count) + + ctx.JSON(http.StatusOK, convert.ToActivities(ctx, feeds, ctx.Doer)) +} diff --git a/routers/api/v1/org/team.go b/routers/api/v1/org/team.go index 597f84620604..50439251cc05 100644 --- a/routers/api/v1/org/team.go +++ b/routers/api/v1/org/team.go @@ -9,6 +9,7 @@ import ( "net/http" "code.gitea.io/gitea/models" + activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" @@ -792,3 +793,55 @@ func SearchTeam(ctx *context.APIContext) { "data": apiTeams, }) } + +func ListTeamActivityFeeds(ctx *context.APIContext) { + // swagger:operation GET /teams/{id}/activities/feeds organization orgListTeamActivityFeeds + // --- + // summary: List a team's activity feeds + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the team + // type: integer + // format: int64 + // required: true + // - name: date + // in: query + // description: the date of the activities to be found + // type: string + // format: date + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/ActivityFeedsList" + // "404": + // "$ref": "#/responses/notFound" + + listOptions := utils.GetListOptions(ctx) + + opts := activities_model.GetFeedsOptions{ + RequestedTeam: ctx.Org.Team, + Actor: ctx.Doer, + IncludePrivate: true, + Date: ctx.FormString("date"), + ListOptions: listOptions, + } + + feeds, count, err := activities_model.GetFeeds(ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetFeeds", err) + return + } + ctx.SetTotalCountHeader(count) + + ctx.JSON(http.StatusOK, convert.ToActivities(ctx, feeds, ctx.Doer)) +} diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index 60e71495e87b..48ace3a8e02c 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -10,6 +10,7 @@ import ( "strings" "time" + activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" @@ -1199,3 +1200,59 @@ func ValidateIssueConfig(ctx *context.APIContext) { ctx.JSON(http.StatusOK, api.IssueConfigValidation{Valid: false, Message: err.Error()}) } } + +func ListRepoActivityFeeds(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/activities/feeds repository repoListActivityFeeds + // --- + // summary: List a repository's activity feeds + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: date + // in: query + // description: the date of the activities to be found + // type: string + // format: date + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/ActivityFeedsList" + // "404": + // "$ref": "#/responses/notFound" + + listOptions := utils.GetListOptions(ctx) + + opts := activities_model.GetFeedsOptions{ + RequestedRepo: ctx.Repo.Repository, + Actor: ctx.Doer, + IncludePrivate: true, + Date: ctx.FormString("date"), + ListOptions: listOptions, + } + + feeds, count, err := activities_model.GetFeeds(ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetFeeds", err) + return + } + ctx.SetTotalCountHeader(count) + + ctx.JSON(http.StatusOK, convert.ToActivities(ctx, feeds, ctx.Doer)) +} diff --git a/routers/api/v1/swagger/activity.go b/routers/api/v1/swagger/activity.go new file mode 100644 index 000000000000..95e1ba903592 --- /dev/null +++ b/routers/api/v1/swagger/activity.go @@ -0,0 +1,15 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package swagger + +import ( + api "code.gitea.io/gitea/modules/structs" +) + +// ActivityFeedsList +// swagger:response ActivityFeedsList +type swaggerActivityFeedsList struct { + // in:body + Body []api.Activity `json:"body"` +} diff --git a/routers/api/v1/user/user.go b/routers/api/v1/user/user.go index 6fd4b3a95cf0..314116962b55 100644 --- a/routers/api/v1/user/user.go +++ b/routers/api/v1/user/user.go @@ -145,3 +145,60 @@ func GetUserHeatmapData(ctx *context.APIContext) { } ctx.JSON(http.StatusOK, heatmap) } + +func ListUserActivityFeeds(ctx *context.APIContext) { + // swagger:operation GET /users/{username}/activities/feeds user userListActivityFeeds + // --- + // summary: List a user's activity feeds + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user + // type: string + // required: true + // - name: only-performed-by + // in: query + // description: if true, only show actions performed by the requested user + // type: boolean + // - name: date + // in: query + // description: the date of the activities to be found + // type: string + // format: date + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/ActivityFeedsList" + // "404": + // "$ref": "#/responses/notFound" + + includePrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID) + listOptions := utils.GetListOptions(ctx) + + opts := activities_model.GetFeedsOptions{ + RequestedUser: ctx.ContextUser, + Actor: ctx.Doer, + IncludePrivate: includePrivate, + OnlyPerformedBy: ctx.FormBool("only-performed-by"), + Date: ctx.FormString("date"), + ListOptions: listOptions, + } + + feeds, count, err := activities_model.GetFeeds(ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetFeeds", err) + return + } + ctx.SetTotalCountHeader(count) + + ctx.JSON(http.StatusOK, convert.ToActivities(ctx, feeds, ctx.Doer)) +} diff --git a/services/convert/activity.go b/services/convert/activity.go new file mode 100644 index 000000000000..2aaa86607b7e --- /dev/null +++ b/services/convert/activity.go @@ -0,0 +1,52 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package convert + +import ( + "context" + + activities_model "code.gitea.io/gitea/models/activities" + perm_model "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + api "code.gitea.io/gitea/modules/structs" +) + +func ToActivity(ctx context.Context, ac *activities_model.Action, doer *user_model.User) *api.Activity { + p, err := access_model.GetUserRepoPermission(ctx, ac.Repo, doer) + if err != nil { + log.Error("GetUserRepoPermission[%d]: %v", ac.RepoID, err) + p.AccessMode = perm_model.AccessModeNone + } + + result := &api.Activity{ + ID: ac.ID, + UserID: ac.UserID, + OpType: ac.OpType.String(), + ActUserID: ac.ActUserID, + ActUser: ToUser(ctx, ac.ActUser, doer), + RepoID: ac.RepoID, + Repo: ToRepo(ctx, ac.Repo, p.AccessMode), + RefName: ac.RefName, + IsPrivate: ac.IsPrivate, + Content: ac.Content, + Created: ac.CreatedUnix.AsTime(), + } + + if ac.Comment != nil { + result.CommentID = ac.CommentID + result.Comment = ToComment(ctx, ac.Comment) + } + + return result +} + +func ToActivities(ctx context.Context, al activities_model.ActionList, doer *user_model.User) []*api.Activity { + result := make([]*api.Activity, 0, len(al)) + for _, ac := range al { + result = append(result, ToActivity(ctx, ac, doer)) + } + return result +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index a8ba40740ec3..aa447ca05070 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -1411,6 +1411,54 @@ } } }, + "/orgs/{org}/activities/feeds": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "List an organization's activity feeds", + "operationId": "orgListActivityFeeds", + "parameters": [ + { + "type": "string", + "description": "name of the org", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "date", + "description": "the date of the activities to be found", + "name": "date", + "in": "query" + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ActivityFeedsList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/orgs/{org}/hooks": { "get": { "produces": [ @@ -2854,6 +2902,61 @@ } } }, + "/repos/{owner}/{repo}/activities/feeds": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "List a repository's activity feeds", + "operationId": "repoListActivityFeeds", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "date", + "description": "the date of the activities to be found", + "name": "date", + "in": "query" + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ActivityFeedsList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/archive/{archive}": { "get": { "produces": [ @@ -12645,6 +12748,55 @@ } } }, + "/teams/{id}/activities/feeds": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "List a team's activity feeds", + "operationId": "orgListTeamActivityFeeds", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "id of the team", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "date", + "description": "the date of the activities to be found", + "name": "date", + "in": "query" + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ActivityFeedsList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/teams/{id}/members": { "get": { "produces": [ @@ -14304,6 +14456,60 @@ } } }, + "/users/{username}/activities/feeds": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "List a user's activity feeds", + "operationId": "userListActivityFeeds", + "parameters": [ + { + "type": "string", + "description": "username of user", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "if true, only show actions performed by the requested user", + "name": "only-performed-by", + "in": "query" + }, + { + "type": "string", + "format": "date", + "description": "the date of the activities to be found", + "name": "date", + "in": "query" + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ActivityFeedsList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/users/{username}/followers": { "get": { "produces": [ @@ -14894,6 +15100,67 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "Activity": { + "type": "object", + "properties": { + "act_user": { + "$ref": "#/definitions/User" + }, + "act_user_id": { + "type": "integer", + "format": "int64", + "x-go-name": "ActUserID" + }, + "comment": { + "$ref": "#/definitions/Comment" + }, + "comment_id": { + "type": "integer", + "format": "int64", + "x-go-name": "CommentID" + }, + "content": { + "type": "string", + "x-go-name": "Content" + }, + "created": { + "type": "string", + "format": "date-time", + "x-go-name": "Created" + }, + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "is_private": { + "type": "boolean", + "x-go-name": "IsPrivate" + }, + "op_type": { + "type": "string", + "x-go-name": "OpType" + }, + "ref_name": { + "type": "string", + "x-go-name": "RefName" + }, + "repo": { + "$ref": "#/definitions/Repository" + }, + "repo_id": { + "type": "integer", + "format": "int64", + "x-go-name": "RepoID" + }, + "user_id": { + "type": "integer", + "format": "int64", + "x-go-name": "UserID" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "ActivityPub": { "description": "ActivityPub type", "type": "object", @@ -20942,6 +21209,15 @@ } } }, + "ActivityFeedsList": { + "description": "ActivityFeedsList", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Activity" + } + } + }, "ActivityPub": { "description": "ActivityPub", "schema": {