diff --git a/docs/content/doc/advanced/mail-templates-us.md b/docs/content/doc/advanced/mail-templates-us.md new file mode 100644 index 0000000000..ffe2d4a27b --- /dev/null +++ b/docs/content/doc/advanced/mail-templates-us.md @@ -0,0 +1,272 @@ +--- +date: "2019-10-23T17:00:00-03:00" +title: "Mail templates" +slug: "mail-templates" +weight: 45 +toc: true +draft: false +menu: + sidebar: + parent: "advanced" + name: "Mail templates" + weight: 45 + identifier: "mail-templates" +--- + +# Mail templates + +To craft the e-mail subject and contents for certain operations, Gitea can be customized by using templates. The templates +for these functions are located under the [`custom` directory](https://docs.gitea.io/en-us/customizing-gitea/). +Gitea has an internal template that serves as default in case there's no custom alternative. + +Custom templates are loaded when Gitea starts. Changes made to them are not recognized until Gitea is restarted again. + +## Mail notifications supporting templates + +Currently, the following notification events make use of templates: + +| Action name | Usage | +|---------------|--------------------------------------------------------------------------------------------------------------| +| `new` | A new issue or pull request was created. | +| `comment` | A new comment was created in an existing issue or pull request. | +| `close` | An issue or pull request was closed. | +| `reopen` | An issue or pull request was reopened. | +| `review` | The head comment of a review in a pull request. | +| `code` | A single comment on the code of a pull request. | +| `assigned` | Used was assigned to an issue or pull request. | +| `default` | Any action not included in the above categories, or when the corresponding category template is not present. | + +The path for the template of a particular message type is: + +``` +custom/templates/mail/{action type}/{action name}.tmpl +``` + +Where `{action type}` is one of `issue` or `pull` (for pull requests), and `{action name}` is one of the names listed above. + +For example, the specific template for a mail regarding a comment in a pull request is: +``` +custom/templates/mail/pull/comment.tmpl +``` + +However, creating templates for each and every action type/name combination is not required. +A fallback system is used to choose the appropriate template for an event. The _first existing_ +template on this list is used: + +* The specific template for the desired **action type** and **action name**. +* The template for action type `issue` and the desired **action name**. +* The template for the desired **action type**, action name `default`. +* The template for action type `issue`, action name `default`. + +The only mandatory template is action type `issue`, action name `default`, which is already embedded in Gitea +unless it's overridden by the user in the `custom` directory. + +## Template syntax + +Mail templates are UTF-8 encoded text files that need to follow one of the following formats: + +``` +Text and macros for the subject line +------------ +Text and macros for the mail body +``` + +or + +``` +Text and macros for the mail body +``` + +Specifying a _subject_ section is optional (and therefore also the dash line separator). When used, the separator between +_subject_ and _mail body_ templates requires at least three dashes; no other characters are allowed in the separator line. + + +_Subject_ and _mail body_ are parsed by [Golang's template engine](https://golang.org/pkg/text/template/) and +are provided with a _metadata context_ assembled for each notification. The context contains the following elements: + +| Name | Type | Available | Usage | +|--------------------|----------------|---------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `.FallbackSubject` | string | Always | A default subject line. See Below. | +| `.Subject` | string | Only in body | The _subject_, once resolved. | +| `.Body` | string | Always | The message of the issue, pull request or comment, parsed from Markdown into HTML and sanitized. Do not confuse with the _mail body_ | +| `.Link` | string | Always | The address of the originating issue, pull request or comment. | +| `.Issue` | models.Issue | Always | The issue (or pull request) originating the notification. To get data specific to a pull request (e.g. `HasMerged`), `.Issue.PullRequest` can be used, but care should be taken as this field will be `nil` if the issue is *not* a pull request. | +| `.Comment` | models.Comment | If applicable | If the notification is from a comment added to an issue or pull request, this will contain the information about the comment. | +| `.IsPull` | bool | Always | `true` if the mail notification is associated with a pull request (i.e. `.Issue.PullRequest` is not `nil`). | +| `.Repo` | string | Always | Name of the repository, including owner name (e.g. `mike/stuff`) | +| `.User` | models.User | Always | Owner of the repository from which the event originated. To get the user name (e.g. `mike`),`.User.Name` can be used. | +| `.Doer` | models.User | Always | User that executed the action triggering the notification event. To get the user name (e.g. `rhonda`), `.Doer.Name` can be used. | +| `.IsMention` | bool | Always | `true` if this notification was only generated because the user was mentioned in the comment, while not being subscribed to the source. It will be `false` if the recipient was subscribed to the issue or repository. | +| `.SubjectPrefix` | string | Always | `Re: ` if the notification is about other than issue or pull request creation; otherwise an empty string. | +| `.ActionType` | string | Always | `"issue"` or `"pull"`. Will correspond to the actual _action type_ independently of which template was selected. | +| `.ActionName` | string | Always | It will be one of the action types described above (`new`, `comment`, etc.), and will correspond to the actual _action name_ independently of which template was selected. | + +All names are case sensitive. + +### The _subject_ part of the template + +The template engine used for the mail _subject_ is golang's [`text/template`](https://golang.org/pkg/text/template/). +Please refer to the linked documentation for details about its syntax. + +The _subject_ is built using the following steps: + +* A template is selected according to the type of notification and to what templates are present. +* The template is parsed and resolved (e.g. `{{.Issue.Index}}` is converted to the number of the issue + or pull request). +* All space-like characters (e.g. `TAB`, `LF`, etc.) are converted to normal spaces. +* All leading, trailing and redundant spaces are removed. +* The string is truncated to its first 256 runes (characters). + +If the end result is an empty string, **or** no subject template was available (i.e. the selected template +did not include a subject part), Gitea's **internal default** will be used. + +The internal default (fallback) subject is the equivalent of: + +``` +{{.SubjectPrefix}}[{{.Repo}}] {{.Issue.Title}} (#.Issue.Index) +``` + +For example: `Re: [mike/stuff] New color palette (#38)` + +Gitea's default subject can also be found in the template _metadata_ as `.FallbackSubject` from any of +the two templates, even if a valid subject template is present. + +### The _mail body_ part of the template + +The template engine used for the _mail body_ is golang's [`html/template`](https://golang.org/pkg/html/template/). +Please refer to the linked documentation for details about its syntax. + +The _mail body_ is parsed after the mail subject, so there is an additional _metadata_ field which is +the actual rendered subject, after all considerations. + +The expected result is HTML (including structural elements like`<html>`, `<body>`, etc.). Styling +through `<style>` blocks, `class` and `style` attributes is possible. However, `html/template` +does some [automatic escaping](https://golang.org/pkg/html/template/#hdr-Contexts) that should be considered. + +Attachments (such as images or external style sheets) are not supported. However, other templates can +be referenced too, for example to provide the contents of a `<style>` element in a centralized fashion. +The external template must be placed under `custom/mail` and referenced relative to that directory. +For example, `custom/mail/styles/base.tmpl` can be included using `{{template styles/base}}`. + +The mail is sent with `Content-Type: multipart/alternative`, so the body is sent in both HTML +and text formats. The latter is obtained by stripping the HTML markup. + +## Troubleshooting + +How a mail is rendered is directly dependent on the capabilities of the mail application. Many mail +clients don't even support HTML, so they show the text version included in the generated mail. + +If the template fails to render, it will be noticed only at the moment the mail is sent. +A default subject is used if the subject template fails, and whatever was rendered successfully +from the the _mail body_ is used, disregarding the rest. + +Please check [Gitea's logs](https://docs.gitea.io/en-us/logging-configuration/) for error messages in case of trouble. + +## Example + +`custom/templates/mail/issue/default.tmpl`: + +``` +[{{.Repo}}] @{{.Doer.Name}} +{{if eq .ActionName "new"}} + created +{{else if eq .ActionName "comment"}} + commented on +{{else if eq .ActionName "close"}} + closed +{{else if eq .ActionName "reopen"}} + reopened +{{else}} + updated +{{end}} +{{if eq .ActionType "issue"}} + issue +{{else}} + pull request +{{end}} +#{{.Issue.Index}}: {{.Issue.Title}} +------------ +<!DOCTYPE html> +<html> +<head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> + <title>{{.Subject}}</title> +</head> + +<body> + {{if .IsMention}} + <p> + You are receiving this because @{{.Doer.Name}} mentioned you. + </p> + {{end}} + <p> + <p> + <a href="{{AppURL}}/{{.Doer.LowerName}}">@{{.Doer.Name}}</a> + {{if not (eq .Doer.FullName "")}} + ({{.Doer.FullName}}) + {{end}} + {{if eq .ActionName "new"}} + created + {{else if eq .ActionName "close"}} + closed + {{else if eq .ActionName "reopen"}} + reopened + {{else}} + updated + {{end}} + <a href="{{.Link}}">{{.Repo}}#{{.Issue.Index}}</a>. + </p> + {{if not (eq .Body "")}} + <h3>Message content:</h3> + <hr> + {{.Body | Str2html}} + {{end}} + </p> + <hr> + <p> + <a href="{{.Link}}">View it on Gitea</a>. + </p> +</body> +</html> +``` + +This template produces something along these lines: + +#### Subject + +> [mike/stuff] @rhonda commented on pull request #38: New color palette + +#### Mail body + +> [@rhonda](#) (Rhonda Myers) updated [mike/stuff#38](#). +> +> #### Message content: +> +> \__________________________________________________________________ +> +> Mike, I think we should tone down the blues a little. +> \__________________________________________________________________ +> +> [View it on Gitea](#). + +## Advanced + +The template system contains several functions that can be used to further process and format +the messages. Here's a list of some of them: + +| Name | Parameters | Available | Usage | +|----------------------|-------------|-----------|---------------------------------------------------------------------| +| `AppUrl` | - | Any | Gitea's URL | +| `AppName` | - | Any | Set from `app.ini`, usually "Gitea" | +| `AppDomain` | - | Any | Gitea's host name | +| `EllipsisString` | string, int | Any | Truncates a string to the specified length; adds ellipsis as needed | +| `Str2html` | string | Body only | Sanitizes text by removing any HTML tags from it. | + +These are _functions_, not metadata, so they have to be used: + +``` +Like this: {{Str2html "Escape<my>text"}} +Or this: {{"Escape<my>text" | Str2html}} +Or this: {{AppUrl}} +But not like this: {{.AppUrl}} +``` diff --git a/integrations/api_team_test.go b/integrations/api_team_test.go index 38e202f239..e25ffdf7b1 100644 --- a/integrations/api_team_test.go +++ b/integrations/api_team_test.go @@ -55,37 +55,44 @@ func TestAPITeam(t *testing.T) { // Create team. teamToCreate := &api.CreateTeamOption{ - Name: "team1", - Description: "team one", - Permission: "write", - Units: []string{"repo.code", "repo.issues"}, + Name: "team1", + Description: "team one", + IncludesAllRepositories: true, + Permission: "write", + Units: []string{"repo.code", "repo.issues"}, } req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/teams?token=%s", org.Name, token), teamToCreate) resp = session.MakeRequest(t, req, http.StatusCreated) DecodeJSON(t, resp, &apiTeam) - checkTeamResponse(t, &apiTeam, teamToCreate.Name, teamToCreate.Description, teamToCreate.Permission, teamToCreate.Units) - checkTeamBean(t, apiTeam.ID, teamToCreate.Name, teamToCreate.Description, teamToCreate.Permission, teamToCreate.Units) + checkTeamResponse(t, &apiTeam, teamToCreate.Name, teamToCreate.Description, teamToCreate.IncludesAllRepositories, + teamToCreate.Permission, teamToCreate.Units) + checkTeamBean(t, apiTeam.ID, teamToCreate.Name, teamToCreate.Description, teamToCreate.IncludesAllRepositories, + teamToCreate.Permission, teamToCreate.Units) teamID := apiTeam.ID // Edit team. teamToEdit := &api.EditTeamOption{ - Name: "teamone", - Description: "team 1", - Permission: "admin", - Units: []string{"repo.code", "repo.pulls", "repo.releases"}, + Name: "teamone", + Description: "team 1", + IncludesAllRepositories: false, + Permission: "admin", + Units: []string{"repo.code", "repo.pulls", "repo.releases"}, } req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/teams/%d?token=%s", teamID, token), teamToEdit) resp = session.MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &apiTeam) - checkTeamResponse(t, &apiTeam, teamToEdit.Name, teamToEdit.Description, teamToEdit.Permission, teamToEdit.Units) - checkTeamBean(t, apiTeam.ID, teamToEdit.Name, teamToEdit.Description, teamToEdit.Permission, teamToEdit.Units) + checkTeamResponse(t, &apiTeam, teamToEdit.Name, teamToEdit.Description, teamToEdit.IncludesAllRepositories, + teamToEdit.Permission, teamToEdit.Units) + checkTeamBean(t, apiTeam.ID, teamToEdit.Name, teamToEdit.Description, teamToEdit.IncludesAllRepositories, + teamToEdit.Permission, teamToEdit.Units) // Read team. teamRead := models.AssertExistsAndLoadBean(t, &models.Team{ID: teamID}).(*models.Team) req = NewRequestf(t, "GET", "/api/v1/teams/%d?token="+token, teamID) resp = session.MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &apiTeam) - checkTeamResponse(t, &apiTeam, teamRead.Name, teamRead.Description, teamRead.Authorize.String(), teamRead.GetUnitNames()) + checkTeamResponse(t, &apiTeam, teamRead.Name, teamRead.Description, teamRead.IncludesAllRepositories, + teamRead.Authorize.String(), teamRead.GetUnitNames()) // Delete team. req = NewRequestf(t, "DELETE", "/api/v1/teams/%d?token="+token, teamID) @@ -93,19 +100,20 @@ func TestAPITeam(t *testing.T) { models.AssertNotExistsBean(t, &models.Team{ID: teamID}) } -func checkTeamResponse(t *testing.T, apiTeam *api.Team, name, description string, permission string, units []string) { +func checkTeamResponse(t *testing.T, apiTeam *api.Team, name, description string, includesAllRepositories bool, permission string, units []string) { assert.Equal(t, name, apiTeam.Name, "name") assert.Equal(t, description, apiTeam.Description, "description") + assert.Equal(t, includesAllRepositories, apiTeam.IncludesAllRepositories, "includesAllRepositories") assert.Equal(t, permission, apiTeam.Permission, "permission") sort.StringSlice(units).Sort() sort.StringSlice(apiTeam.Units).Sort() assert.EqualValues(t, units, apiTeam.Units, "units") } -func checkTeamBean(t *testing.T, id int64, name, description string, permission string, units []string) { +func checkTeamBean(t *testing.T, id int64, name, description string, includesAllRepositories bool, permission string, units []string) { team := models.AssertExistsAndLoadBean(t, &models.Team{ID: id}).(*models.Team) assert.NoError(t, team.GetUnits(), "GetUnits") - checkTeamResponse(t, convert.ToTeam(team), name, description, permission, units) + checkTeamResponse(t, convert.ToTeam(team), name, description, includesAllRepositories, permission, units) } type TeamSearchResults struct { diff --git a/models/issue_comment.go b/models/issue_comment.go index 90bb8c53ac..63f5f6b778 100644 --- a/models/issue_comment.go +++ b/models/issue_comment.go @@ -535,6 +535,10 @@ func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err return nil, err } + if err = updateCommentInfos(e, opts, comment); err != nil { + return nil, err + } + if err = sendCreateCommentAction(e, opts, comment); err != nil { return nil, err } @@ -546,6 +550,56 @@ func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err return comment, nil } +func updateCommentInfos(e *xorm.Session, opts *CreateCommentOptions, comment *Comment) (err error) { + // Check comment type. + switch opts.Type { + case CommentTypeCode: + if comment.ReviewID != 0 { + if comment.Review == nil { + if err := comment.loadReview(e); err != nil { + return err + } + } + if comment.Review.Type <= ReviewTypePending { + return nil + } + } + fallthrough + case CommentTypeComment: + if _, err = e.Exec("UPDATE `issue` SET num_comments=num_comments+1 WHERE id=?", opts.Issue.ID); err != nil { + return err + } + + // Check attachments + attachments := make([]*Attachment, 0, len(opts.Attachments)) + for _, uuid := range opts.Attachments { + attach, err := getAttachmentByUUID(e, uuid) + if err != nil { + if IsErrAttachmentNotExist(err) { + continue + } + return fmt.Errorf("getAttachmentByUUID [%s]: %v", uuid, err) + } + attachments = append(attachments, attach) + } + + for i := range attachments { + attachments[i].IssueID = opts.Issue.ID + attachments[i].CommentID = comment.ID + // No assign value could be 0, so ignore AllCols(). + if _, err = e.ID(attachments[i].ID).Update(attachments[i]); err != nil { + return fmt.Errorf("update attachment [%d]: %v", attachments[i].ID, err) + } + } + case CommentTypeReopen, CommentTypeClose: + if err = opts.Issue.updateClosedNum(e); err != nil { + return err + } + } + // update the issue's updated_unix column + return updateIssueCols(e, opts.Issue, "updated_unix") +} + func sendCreateCommentAction(e *xorm.Session, opts *CreateCommentOptions, comment *Comment) (err error) { // Compose comment action, could be plain comment, close or reopen issue/pull request. // This object will be used to notify watchers in the end of function. @@ -575,56 +629,16 @@ func sendCreateCommentAction(e *xorm.Session, opts *CreateCommentOptions, commen fallthrough case CommentTypeComment: act.OpType = ActionCommentIssue - - if _, err = e.Exec("UPDATE `issue` SET num_comments=num_comments+1 WHERE id=?", opts.Issue.ID); err != nil { - return err - } - - // Check attachments - attachments := make([]*Attachment, 0, len(opts.Attachments)) - for _, uuid := range opts.Attachments { - attach, err := getAttachmentByUUID(e, uuid) - if err != nil { - if IsErrAttachmentNotExist(err) { - continue - } - return fmt.Errorf("getAttachmentByUUID [%s]: %v", uuid, err) - } - attachments = append(attachments, attach) - } - - for i := range attachments { - attachments[i].IssueID = opts.Issue.ID - attachments[i].CommentID = comment.ID - // No assign value could be 0, so ignore AllCols(). - if _, err = e.ID(attachments[i].ID).Update(attachments[i]); err != nil { - return fmt.Errorf("update attachment [%d]: %v", attachments[i].ID, err) - } - } - case CommentTypeReopen: act.OpType = ActionReopenIssue if opts.Issue.IsPull { act.OpType = ActionReopenPullRequest } - - if err = opts.Issue.updateClosedNum(e); err != nil { - return err - } - case CommentTypeClose: act.OpType = ActionCloseIssue if opts.Issue.IsPull { act.OpType = ActionClosePullRequest } - - if err = opts.Issue.updateClosedNum(e); err != nil { - return err - } - } - // update the issue's updated_unix column - if err = updateIssueCols(e, opts.Issue, "updated_unix"); err != nil { - return err } // Notify watchers for whatever action comes in, ignore if no action type. if act.OpType > 0 { diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index e214f16a2a..5ed70dc4f5 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -264,6 +264,8 @@ var migrations = []Migration{ NewMigration("Add WhitelistDeployKeys to protected branch", addWhitelistDeployKeysToBranches), // v104 -> v105 NewMigration("remove unnecessary columns from label", removeLabelUneededCols), + // v105 -> v106 + NewMigration("add includes_all_repositories to teams", addTeamIncludesAllRepositories), } // Migrate database to current version diff --git a/models/migrations/v105.go b/models/migrations/v105.go new file mode 100644 index 0000000000..6c9a5817af --- /dev/null +++ b/models/migrations/v105.go @@ -0,0 +1,25 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package migrations + +import ( + "xorm.io/xorm" +) + +func addTeamIncludesAllRepositories(x *xorm.Engine) error { + + type Team struct { + ID int64 `xorm:"pk autoincr"` + IncludesAllRepositories bool `xorm:"NOT NULL DEFAULT false"` + } + + if err := x.Sync2(new(Team)); err != nil { + return err + } + + _, err := x.Exec("UPDATE `team` SET `includes_all_repositories` = ? WHERE `name`=?", + true, "Owners") + return err +} diff --git a/models/org.go b/models/org.go index 2cc302dac6..78b035b101 100644 --- a/models/org.go +++ b/models/org.go @@ -48,6 +48,9 @@ func (org *User) GetOwnerTeam() (*Team, error) { } func (org *User) getTeams(e Engine) error { + if org.Teams != nil { + return nil + } return e. Where("org_id=?", org.ID). OrderBy("CASE WHEN name LIKE '" + ownerTeamName + "' THEN '' ELSE name END"). @@ -149,11 +152,12 @@ func CreateOrganization(org, owner *User) (err error) { // Create default owner team. t := &Team{ - OrgID: org.ID, - LowerName: strings.ToLower(ownerTeamName), - Name: ownerTeamName, - Authorize: AccessModeOwner, - NumMembers: 1, + OrgID: org.ID, + LowerName: strings.ToLower(ownerTeamName), + Name: ownerTeamName, + Authorize: AccessModeOwner, + NumMembers: 1, + IncludesAllRepositories: true, } if _, err = sess.Insert(t); err != nil { return fmt.Errorf("insert owner team: %v", err) diff --git a/models/org_team.go b/models/org_team.go index a7a179f104..d740e1c240 100644 --- a/models/org_team.go +++ b/models/org_team.go @@ -22,17 +22,18 @@ const ownerTeamName = "Owners" // Team represents a organization team. type Team struct { - ID int64 `xorm:"pk autoincr"` - OrgID int64 `xorm:"INDEX"` - LowerName string - Name string - Description string - Authorize AccessMode - Repos []*Repository `xorm:"-"` - Members []*User `xorm:"-"` - NumRepos int - NumMembers int - Units []*TeamUnit `xorm:"-"` + ID int64 `xorm:"pk autoincr"` + OrgID int64 `xorm:"INDEX"` + LowerName string + Name string + Description string + Authorize AccessMode + Repos []*Repository `xorm:"-"` + Members []*User `xorm:"-"` + NumRepos int + NumMembers int + Units []*TeamUnit `xorm:"-"` + IncludesAllRepositories bool `xorm:"NOT NULL DEFAULT false"` } // SearchTeamOptions holds the search options @@ -149,6 +150,9 @@ func (t *Team) IsMember(userID int64) bool { } func (t *Team) getRepositories(e Engine) error { + if t.Repos != nil { + return nil + } return e.Join("INNER", "team_repo", "repository.id = team_repo.repo_id"). Where("team_repo.team_id=?", t.ID). OrderBy("repository.name"). @@ -220,6 +224,25 @@ func (t *Team) addRepository(e Engine, repo *Repository) (err error) { return nil } +// addAllRepositories adds all repositories to the team. +// If the team already has some repositories they will be left unchanged. +func (t *Team) addAllRepositories(e Engine) error { + var orgRepos []Repository + if err := e.Where("owner_id = ?", t.OrgID).Find(&orgRepos); err != nil { + return fmt.Errorf("get org repos: %v", err) + } + + for _, repo := range orgRepos { + if !t.hasRepository(e, repo.ID) { + if err := t.addRepository(e, &repo); err != nil { + return fmt.Errorf("addRepository: %v", err) + } + } + } + + return nil +} + // AddRepository adds new repository to team of organization. func (t *Team) AddRepository(repo *Repository) (err error) { if repo.OwnerID != t.OrgID { @@ -241,6 +264,8 @@ func (t *Team) AddRepository(repo *Repository) (err error) { return sess.Commit() } +// removeRepository removes a repository from a team and recalculates access +// Note: Repository shall not be removed from team if it includes all repositories (unless the repository is deleted) func (t *Team) removeRepository(e Engine, repo *Repository, recalculate bool) (err error) { if err = removeTeamRepo(e, t.ID, repo.ID); err != nil { return err @@ -284,11 +309,16 @@ func (t *Team) removeRepository(e Engine, repo *Repository, recalculate bool) (e } // RemoveRepository removes repository from team of organization. +// If the team shall include all repositories the request is ignored. func (t *Team) RemoveRepository(repoID int64) error { if !t.HasRepository(repoID) { return nil } + if t.IncludesAllRepositories { + return nil + } + repo, err := GetRepositoryByID(repoID) if err != nil { return err @@ -394,6 +424,14 @@ func NewTeam(t *Team) (err error) { } } + // Add all repositories to the team if it has access to all of them. + if t.IncludesAllRepositories { + err = t.addAllRepositories(sess) + if err != nil { + return fmt.Errorf("addAllRepositories: %v", err) + } + } + // Update organization number of teams. if _, err = sess.Exec("UPDATE `user` SET num_teams=num_teams+1 WHERE id = ?", t.OrgID); err != nil { errRollback := sess.Rollback() @@ -446,7 +484,7 @@ func GetTeamByID(teamID int64) (*Team, error) { } // UpdateTeam updates information of team. -func UpdateTeam(t *Team, authChanged bool) (err error) { +func UpdateTeam(t *Team, authChanged bool, includeAllChanged bool) (err error) { if len(t.Name) == 0 { return errors.New("empty team name") } @@ -511,6 +549,14 @@ func UpdateTeam(t *Team, authChanged bool) (err error) { } } + // Add all repositories to the team if it has access to all of them. + if includeAllChanged && t.IncludesAllRepositories { + err = t.addAllRepositories(sess) + if err != nil { + return fmt.Errorf("addAllRepositories: %v", err) + } + } + return sess.Commit() } diff --git a/models/org_team_test.go b/models/org_team_test.go index 06ab4637d8..b7e2ef113d 100644 --- a/models/org_team_test.go +++ b/models/org_team_test.go @@ -5,9 +5,12 @@ package models import ( + "fmt" "strings" "testing" + "code.gitea.io/gitea/modules/structs" + "github.com/stretchr/testify/assert" ) @@ -206,7 +209,7 @@ func TestUpdateTeam(t *testing.T) { team.Name = "newName" team.Description = strings.Repeat("A long description!", 100) team.Authorize = AccessModeAdmin - assert.NoError(t, UpdateTeam(team, true)) + assert.NoError(t, UpdateTeam(team, true, false)) team = AssertExistsAndLoadBean(t, &Team{Name: "newName"}).(*Team) assert.True(t, strings.HasPrefix(team.Description, "A long description!")) @@ -225,7 +228,7 @@ func TestUpdateTeam2(t *testing.T) { team.LowerName = "owners" team.Name = "Owners" team.Description = strings.Repeat("A long description!", 100) - err := UpdateTeam(team, true) + err := UpdateTeam(team, true, false) assert.True(t, IsErrTeamAlreadyExist(err)) CheckConsistencyFor(t, &Team{ID: team.ID}) @@ -374,3 +377,133 @@ func TestUsersInTeamsCount(t *testing.T) { test([]int64{1, 2, 3, 4, 5}, []int64{2, 5}, 2) // userid 2,4 test([]int64{1, 2, 3, 4, 5}, []int64{2, 3, 5}, 3) // userid 2,4,5 } + +func TestIncludesAllRepositoriesTeams(t *testing.T) { + assert.NoError(t, PrepareTestDatabase()) + + testTeamRepositories := func(teamID int64, repoIds []int64) { + team := AssertExistsAndLoadBean(t, &Team{ID: teamID}).(*Team) + assert.NoError(t, team.GetRepositories(), "%s: GetRepositories", team.Name) + assert.Len(t, team.Repos, team.NumRepos, "%s: len repo", team.Name) + assert.Equal(t, len(repoIds), len(team.Repos), "%s: repo count", team.Name) + for i, rid := range repoIds { + if rid > 0 { + assert.True(t, team.HasRepository(rid), "%s: HasRepository(%d) %d", rid, i) + } + } + } + + // Get an admin user. + user, err := GetUserByID(1) + assert.NoError(t, err, "GetUserByID") + + // Create org. + org := &User{ + Name: "All repo", + IsActive: true, + Type: UserTypeOrganization, + Visibility: structs.VisibleTypePublic, + } + assert.NoError(t, CreateOrganization(org, user), "CreateOrganization") + + // Check Owner team. + ownerTeam, err := org.GetOwnerTeam() + assert.NoError(t, err, "GetOwnerTeam") + assert.True(t, ownerTeam.IncludesAllRepositories, "Owner team includes all repositories") + + // Create repos. + repoIds := make([]int64, 0) + for i := 0; i < 3; i++ { + r, err := CreateRepository(user, org, CreateRepoOptions{Name: fmt.Sprintf("repo-%d", i)}) + assert.NoError(t, err, "CreateRepository %d", i) + if r != nil { + repoIds = append(repoIds, r.ID) + } + } + // Get fresh copy of Owner team after creating repos. + ownerTeam, err = org.GetOwnerTeam() + assert.NoError(t, err, "GetOwnerTeam") + + // Create teams and check repositories. + teams := []*Team{ + ownerTeam, + { + OrgID: org.ID, + Name: "team one", + Authorize: AccessModeRead, + IncludesAllRepositories: true, + }, + { + OrgID: org.ID, + Name: "team 2", + Authorize: AccessModeRead, + IncludesAllRepositories: false, + }, + { + OrgID: org.ID, + Name: "team three", + Authorize: AccessModeWrite, + IncludesAllRepositories: true, + }, + { + OrgID: org.ID, + Name: "team 4", + Authorize: AccessModeWrite, + IncludesAllRepositories: false, + }, + } + teamRepos := [][]int64{ + repoIds, + repoIds, + {}, + repoIds, + {}, + } + for i, team := range teams { + if i > 0 { // first team is Owner. + assert.NoError(t, NewTeam(team), "%s: NewTeam", team.Name) + } + testTeamRepositories(team.ID, teamRepos[i]) + } + + // Update teams and check repositories. + teams[3].IncludesAllRepositories = false + teams[4].IncludesAllRepositories = true + teamRepos[4] = repoIds + for i, team := range teams { + assert.NoError(t, UpdateTeam(team, false, true), "%s: UpdateTeam", team.Name) + testTeamRepositories(team.ID, teamRepos[i]) + } + + // Create repo and check teams repositories. + org.Teams = nil // Reset teams to allow their reloading. + r, err := CreateRepository(user, org, CreateRepoOptions{Name: "repo-last"}) + assert.NoError(t, err, "CreateRepository last") + if r != nil { + repoIds = append(repoIds, r.ID) + } + teamRepos[0] = repoIds + teamRepos[1] = repoIds + teamRepos[4] = repoIds + for i, team := range teams { + testTeamRepositories(team.ID, teamRepos[i]) + } + + // Remove repo and check teams repositories. + assert.NoError(t, DeleteRepository(user, org.ID, repoIds[0]), "DeleteRepository") + teamRepos[0] = repoIds[1:] + teamRepos[1] = repoIds[1:] + teamRepos[3] = repoIds[1:3] + teamRepos[4] = repoIds[1:] + for i, team := range teams { + testTeamRepositories(team.ID, teamRepos[i]) + } + + // Wipe created items. + for i, rid := range repoIds { + if i > 0 { // first repo already deleted. + assert.NoError(t, DeleteRepository(user, org.ID, rid), "DeleteRepository %d", i) + } + } + assert.NoError(t, DeleteOrganization(org), "DeleteOrganization") +} diff --git a/models/repo.go b/models/repo.go index 7945cb309d..89e579d1ec 100644 --- a/models/repo.go +++ b/models/repo.go @@ -1447,14 +1447,17 @@ func createRepository(e *xorm.Session, doer, u *User, repo *Repository) (err err } u.NumRepos++ - // Give access to all members in owner team. + // Give access to all members in teams with access to all repositories. if u.IsOrganization() { - t, err := u.getOwnerTeam(e) - if err != nil { - return fmt.Errorf("getOwnerTeam: %v", err) + if err := u.GetTeams(); err != nil { + return fmt.Errorf("GetTeams: %v", err) } - if err = t.addRepository(e, repo); err != nil { - return fmt.Errorf("addRepository: %v", err) + for _, t := range u.Teams { + if t.IncludesAllRepositories { + if err := t.addRepository(e, repo); err != nil { + return fmt.Errorf("addRepository: %v", err) + } + } } } else if err = repo.recalculateAccesses(e); err != nil { // Organization automatically called this in addRepository method. @@ -1641,11 +1644,15 @@ func TransferOwnership(doer *User, newOwnerName string, repo *Repository) error } if newOwner.IsOrganization() { - t, err := newOwner.getOwnerTeam(sess) - if err != nil { - return fmt.Errorf("getOwnerTeam: %v", err) - } else if err = t.addRepository(sess, repo); err != nil { - return fmt.Errorf("add to owner team: %v", err) + if err := newOwner.GetTeams(); err != nil { + return fmt.Errorf("GetTeams: %v", err) + } + for _, t := range newOwner.Teams { + if t.IncludesAllRepositories { + if err := t.addRepository(sess, repo); err != nil { + return fmt.Errorf("addRepository: %v", err) + } + } } } else if err = repo.recalculateAccesses(sess); err != nil { // Organization called this in addRepository method. diff --git a/models/review.go b/models/review.go index 58660b2e3d..89a26d6fdb 100644 --- a/models/review.go +++ b/models/review.go @@ -129,13 +129,17 @@ func (r *Review) publish(e *xorm.Engine) error { go func(en *xorm.Engine, review *Review, comm *Comment) { sess := en.NewSession() defer sess.Close() - if err := sendCreateCommentAction(sess, &CreateCommentOptions{ + opts := &CreateCommentOptions{ Doer: comm.Poster, Issue: review.Issue, Repo: review.Issue.Repo, Type: comm.Type, Content: comm.Content, - }, comm); err != nil { + } + if err := updateCommentInfos(sess, opts, comm); err != nil { + log.Warn("updateCommentInfos: %v", err) + } + if err := sendCreateCommentAction(sess, opts, comm); err != nil { log.Warn("sendCreateCommentAction: %v", err) } }(e, r, comment) diff --git a/modules/auth/org.go b/modules/auth/org.go index 94e659cb5b..509358882a 100644 --- a/modules/auth/org.go +++ b/modules/auth/org.go @@ -62,6 +62,7 @@ type CreateTeamForm struct { Description string `binding:"MaxSize(255)"` Permission string Units []models.UnitType + RepoAccess string } // Validate validates the fields diff --git a/modules/git/commit.go b/modules/git/commit.go index 45b943e79e..ce55dd55f6 100644 --- a/modules/git/commit.go +++ b/modules/git/commit.go @@ -248,6 +248,16 @@ func CommitChanges(repoPath string, opts CommitChangesOptions) error { return err } +// AllCommitsCount returns count of all commits in repository +func AllCommitsCount(repoPath string) (int64, error) { + stdout, err := NewCommand("rev-list", "--all", "--count").RunInDir(repoPath) + if err != nil { + return 0, err + } + + return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64) +} + func commitsCount(repoPath, revision, relpath string) (int64, error) { cmd := NewCommand("rev-list", "--count") cmd.AddArguments(revision) diff --git a/modules/git/repo.go b/modules/git/repo.go index e1d75ca4aa..4c6690b913 100644 --- a/modules/git/repo.go +++ b/modules/git/repo.go @@ -46,6 +46,11 @@ type GPGSettings struct { const prettyLogFormat = `--pretty=format:%H` +// GetAllCommitsCount returns count of all commits in repository +func (repo *Repository) GetAllCommitsCount() (int64, error) { + return AllCommitsCount(repo.Path) +} + func (repo *Repository) parsePrettyFormatLogToList(logs []byte) (*list.List, error) { l := list.New() if len(logs) == 0 { diff --git a/modules/notification/base/notifier.go b/modules/notification/base/notifier.go index 286ebe5d69..72bf52c938 100644 --- a/modules/notification/base/notifier.go +++ b/modules/notification/base/notifier.go @@ -43,4 +43,6 @@ type Notifier interface { NotifyDeleteRelease(doer *models.User, rel *models.Release) NotifyPushCommits(pusher *models.User, repo *models.Repository, refName, oldCommitID, newCommitID string, commits *models.PushCommits) + NotifyCreateRef(doer *models.User, repo *models.Repository, refType, refFullName string) + NotifyDeleteRef(doer *models.User, repo *models.Repository, refType, refFullName string) } diff --git a/modules/notification/base/null.go b/modules/notification/base/null.go index 5b6359cbd5..a9d9d6a164 100644 --- a/modules/notification/base/null.go +++ b/modules/notification/base/null.go @@ -114,3 +114,11 @@ func (*NullNotifier) NotifyMigrateRepository(doer *models.User, u *models.User, // NotifyPushCommits notifies commits pushed to notifiers func (*NullNotifier) NotifyPushCommits(pusher *models.User, repo *models.Repository, refName, oldCommitID, newCommitID string, commits *models.PushCommits) { } + +// NotifyCreateRef notifies branch or tag creation to notifiers +func (*NullNotifier) NotifyCreateRef(doer *models.User, repo *models.Repository, refType, refFullName string) { +} + +// NotifyDeleteRef notifies branch or tag deleteion to notifiers +func (*NullNotifier) NotifyDeleteRef(doer *models.User, repo *models.Repository, refType, refFullName string) { +} diff --git a/modules/notification/notification.go b/modules/notification/notification.go index a5e450ee66..5ac09a72e5 100644 --- a/modules/notification/notification.go +++ b/modules/notification/notification.go @@ -199,3 +199,17 @@ func NotifyPushCommits(pusher *models.User, repo *models.Repository, refName, ol notifier.NotifyPushCommits(pusher, repo, refName, oldCommitID, newCommitID, commits) } } + +// NotifyCreateRef notifies branch or tag creation to notifiers +func NotifyCreateRef(pusher *models.User, repo *models.Repository, refType, refFullName string) { + for _, notifier := range notifiers { + notifier.NotifyCreateRef(pusher, repo, refType, refFullName) + } +} + +// NotifyDeleteRef notifies branch or tag deletion to notifiers +func NotifyDeleteRef(pusher *models.User, repo *models.Repository, refType, refFullName string) { + for _, notifier := range notifiers { + notifier.NotifyDeleteRef(pusher, repo, refType, refFullName) + } +} diff --git a/modules/notification/webhook/webhook.go b/modules/notification/webhook/webhook.go index 39c63edb05..8059ec1c00 100644 --- a/modules/notification/webhook/webhook.go +++ b/modules/notification/webhook/webhook.go @@ -6,11 +6,11 @@ package webhook import ( "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/notification/base" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" - "code.gitea.io/gitea/modules/webhook" webhook_module "code.gitea.io/gitea/modules/webhook" ) @@ -288,7 +288,7 @@ func (m *webhookNotifier) NotifyNewPullRequest(pull *models.PullRequest) { } mode, _ := models.AccessLevel(pull.Issue.Poster, pull.Issue.Repo) - if err := webhook.PrepareWebhooks(pull.Issue.Repo, models.HookEventPullRequest, &api.PullRequestPayload{ + if err := webhook_module.PrepareWebhooks(pull.Issue.Repo, models.HookEventPullRequest, &api.PullRequestPayload{ Action: api.HookIssueOpened, Index: pull.Issue.Index, PullRequest: pull.APIFormat(), @@ -547,7 +547,7 @@ func (m *webhookNotifier) NotifyPullRequestReview(pr *models.PullRequest, review log.Error("models.AccessLevel: %v", err) return } - if err := webhook.PrepareWebhooks(review.Issue.Repo, reviewHookType, &api.PullRequestPayload{ + if err := webhook_module.PrepareWebhooks(review.Issue.Repo, reviewHookType, &api.PullRequestPayload{ Action: api.HookIssueSynchronized, Index: review.Issue.Index, PullRequest: pr.APIFormat(), @@ -562,6 +562,34 @@ func (m *webhookNotifier) NotifyPullRequestReview(pr *models.PullRequest, review } } +func (m *webhookNotifier) NotifyCreateRef(pusher *models.User, repo *models.Repository, refType, refFullName string) { + apiPusher := pusher.APIFormat() + apiRepo := repo.APIFormat(models.AccessModeNone) + refName := git.RefEndName(refFullName) + + gitRepo, err := git.OpenRepository(repo.RepoPath()) + if err != nil { + log.Error("OpenRepository[%s]: %v", repo.RepoPath(), err) + return + } + + shaSum, err := gitRepo.GetBranchCommitID(refName) + if err != nil { + log.Error("GetBranchCommitID[%s]: %v", refFullName, err) + return + } + + if err = webhook_module.PrepareWebhooks(repo, models.HookEventCreate, &api.CreatePayload{ + Ref: refName, + Sha: shaSum, + RefType: refType, + Repo: apiRepo, + Sender: apiPusher, + }); err != nil { + log.Error("PrepareWebhooks: %v", err) + } +} + func (m *webhookNotifier) NotifyPullRequestSynchronized(doer *models.User, pr *models.PullRequest) { if err := pr.LoadIssue(); err != nil { log.Error("pr.LoadIssue: %v", err) @@ -572,7 +600,7 @@ func (m *webhookNotifier) NotifyPullRequestSynchronized(doer *models.User, pr *m return } - if err := webhook.PrepareWebhooks(pr.Issue.Repo, models.HookEventPullRequest, &api.PullRequestPayload{ + if err := webhook_module.PrepareWebhooks(pr.Issue.Repo, models.HookEventPullRequest, &api.PullRequestPayload{ Action: api.HookIssueSynchronized, Index: pr.Issue.Index, PullRequest: pr.Issue.PullRequest.APIFormat(), @@ -582,3 +610,48 @@ func (m *webhookNotifier) NotifyPullRequestSynchronized(doer *models.User, pr *m log.Error("PrepareWebhooks [pull_id: %v]: %v", pr.ID, err) } } + +func (m *webhookNotifier) NotifyDeleteRef(pusher *models.User, repo *models.Repository, refType, refFullName string) { + apiPusher := pusher.APIFormat() + apiRepo := repo.APIFormat(models.AccessModeNone) + refName := git.RefEndName(refFullName) + + if err := webhook_module.PrepareWebhooks(repo, models.HookEventDelete, &api.DeletePayload{ + Ref: refName, + RefType: "branch", + PusherType: api.PusherTypeUser, + Repo: apiRepo, + Sender: apiPusher, + }); err != nil { + log.Error("PrepareWebhooks.(delete branch): %v", err) + } +} + +func sendReleaseHook(doer *models.User, rel *models.Release, action api.HookReleaseAction) { + if err := rel.LoadAttributes(); err != nil { + log.Error("LoadAttributes: %v", err) + return + } + + mode, _ := models.AccessLevel(rel.Publisher, rel.Repo) + if err := webhook_module.PrepareWebhooks(rel.Repo, models.HookEventRelease, &api.ReleasePayload{ + Action: action, + Release: rel.APIFormat(), + Repository: rel.Repo.APIFormat(mode), + Sender: rel.Publisher.APIFormat(), + }); err != nil { + log.Error("PrepareWebhooks: %v", err) + } +} + +func (m *webhookNotifier) NotifyNewRelease(rel *models.Release) { + sendReleaseHook(rel.Publisher, rel, api.HookReleasePublished) +} + +func (m *webhookNotifier) NotifyUpdateRelease(doer *models.User, rel *models.Release) { + sendReleaseHook(doer, rel, api.HookReleaseUpdated) +} + +func (m *webhookNotifier) NotifyDeleteRelease(doer *models.User, rel *models.Release) { + sendReleaseHook(doer, rel, api.HookReleaseDeleted) +} diff --git a/modules/repofiles/action.go b/modules/repofiles/action.go index e5f6bf8718..996363863d 100644 --- a/modules/repofiles/action.go +++ b/modules/repofiles/action.go @@ -14,8 +14,6 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/notification" "code.gitea.io/gitea/modules/setting" - api "code.gitea.io/gitea/modules/structs" - "code.gitea.io/gitea/modules/webhook" ) // CommitRepoActionOptions represent options of a new commit action. @@ -113,81 +111,23 @@ func CommitRepoAction(opts CommitRepoActionOptions) error { return fmt.Errorf("NotifyWatchers: %v", err) } - apiPusher := pusher.APIFormat() - apiRepo := repo.APIFormat(models.AccessModeNone) - - var shaSum string - var isHookEventPush = false + var isHookEventPush = true switch opType { case models.ActionCommitRepo: // Push - isHookEventPush = true - if isNewBranch { - gitRepo, err := git.OpenRepository(repo.RepoPath()) - if err != nil { - log.Error("OpenRepository[%s]: %v", repo.RepoPath(), err) - } - - shaSum, err = gitRepo.GetBranchCommitID(refName) - if err != nil { - log.Error("GetBranchCommitID[%s]: %v", opts.RefFullName, err) - } - if err = webhook.PrepareWebhooks(repo, models.HookEventCreate, &api.CreatePayload{ - Ref: refName, - Sha: shaSum, - RefType: "branch", - Repo: apiRepo, - Sender: apiPusher, - }); err != nil { - return fmt.Errorf("PrepareWebhooks: %v", err) - } + notification.NotifyCreateRef(pusher, repo, "branch", opts.RefFullName) } case models.ActionDeleteBranch: // Delete Branch - isHookEventPush = true - - if err = webhook.PrepareWebhooks(repo, models.HookEventDelete, &api.DeletePayload{ - Ref: refName, - RefType: "branch", - PusherType: api.PusherTypeUser, - Repo: apiRepo, - Sender: apiPusher, - }); err != nil { - return fmt.Errorf("PrepareWebhooks.(delete branch): %v", err) - } + notification.NotifyDeleteRef(pusher, repo, "branch", opts.RefFullName) case models.ActionPushTag: // Create - isHookEventPush = true + notification.NotifyCreateRef(pusher, repo, "tag", opts.RefFullName) - gitRepo, err := git.OpenRepository(repo.RepoPath()) - if err != nil { - log.Error("OpenRepository[%s]: %v", repo.RepoPath(), err) - } - shaSum, err = gitRepo.GetTagCommitID(refName) - if err != nil { - log.Error("GetTagCommitID[%s]: %v", opts.RefFullName, err) - } - if err = webhook.PrepareWebhooks(repo, models.HookEventCreate, &api.CreatePayload{ - Ref: refName, - Sha: shaSum, - RefType: "tag", - Repo: apiRepo, - Sender: apiPusher, - }); err != nil { - return fmt.Errorf("PrepareWebhooks: %v", err) - } case models.ActionDeleteTag: // Delete Tag - isHookEventPush = true - - if err = webhook.PrepareWebhooks(repo, models.HookEventDelete, &api.DeletePayload{ - Ref: refName, - RefType: "tag", - PusherType: api.PusherTypeUser, - Repo: apiRepo, - Sender: apiPusher, - }); err != nil { - return fmt.Errorf("PrepareWebhooks.(delete tag): %v", err) - } + notification.NotifyDeleteRef(pusher, repo, "tag", opts.RefFullName) + default: + isHookEventPush = false } if isHookEventPush { diff --git a/modules/structs/org_team.go b/modules/structs/org_team.go index bea4a10ad4..5053468b4a 100644 --- a/modules/structs/org_team.go +++ b/modules/structs/org_team.go @@ -7,10 +7,11 @@ package structs // Team represents a team in an organization type Team struct { - ID int64 `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Organization *Organization `json:"organization"` + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Organization *Organization `json:"organization"` + IncludesAllRepositories bool `json:"includes_all_repositories"` // enum: none,read,write,admin,owner Permission string `json:"permission"` // example: ["repo.code","repo.issues","repo.ext_issues","repo.wiki","repo.pulls","repo.releases","repo.ext_wiki"] @@ -20,8 +21,9 @@ type Team struct { // CreateTeamOption options for creating a team type CreateTeamOption struct { // required: true - Name string `json:"name" binding:"Required;AlphaDashDot;MaxSize(30)"` - Description string `json:"description" binding:"MaxSize(255)"` + Name string `json:"name" binding:"Required;AlphaDashDot;MaxSize(30)"` + Description string `json:"description" binding:"MaxSize(255)"` + IncludesAllRepositories bool `json:"includes_all_repositories"` // enum: read,write,admin Permission string `json:"permission"` // example: ["repo.code","repo.issues","repo.ext_issues","repo.wiki","repo.pulls","repo.releases","repo.ext_wiki"] @@ -31,8 +33,9 @@ type CreateTeamOption struct { // EditTeamOption options for editing a team type EditTeamOption struct { // required: true - Name string `json:"name" binding:"Required;AlphaDashDot;MaxSize(30)"` - Description string `json:"description" binding:"MaxSize(255)"` + Name string `json:"name" binding:"Required;AlphaDashDot;MaxSize(30)"` + Description string `json:"description" binding:"MaxSize(255)"` + IncludesAllRepositories bool `json:"includes_all_repositories"` // enum: read,write,admin Permission string `json:"permission"` // example: ["repo.code","repo.issues","repo.ext_issues","repo.wiki","repo.pulls","repo.releases","repo.ext_wiki"] diff --git a/modules/templates/dynamic.go b/modules/templates/dynamic.go index 6217f1c3b0..6153e8d027 100644 --- a/modules/templates/dynamic.go +++ b/modules/templates/dynamic.go @@ -11,6 +11,7 @@ import ( "io/ioutil" "path" "strings" + texttmpl "text/template" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -20,7 +21,8 @@ import ( ) var ( - templates = template.New("") + subjectTemplates = texttmpl.New("") + bodyTemplates = template.New("") ) // HTMLRenderer implements the macaron handler for serving HTML templates. @@ -59,9 +61,12 @@ func JSRenderer() macaron.Handler { } // Mailer provides the templates required for sending notification mails. -func Mailer() *template.Template { +func Mailer() (*texttmpl.Template, *template.Template) { + for _, funcs := range NewTextFuncMap() { + subjectTemplates.Funcs(funcs) + } for _, funcs := range NewFuncMap() { - templates.Funcs(funcs) + bodyTemplates.Funcs(funcs) } staticDir := path.Join(setting.StaticRootPath, "templates", "mail") @@ -84,15 +89,7 @@ func Mailer() *template.Template { continue } - _, err = templates.New( - strings.TrimSuffix( - filePath, - ".tmpl", - ), - ).Parse(string(content)) - if err != nil { - log.Warn("Failed to parse template %v", err) - } + buildSubjectBodyTemplate(subjectTemplates, bodyTemplates, strings.TrimSuffix(filePath, ".tmpl"), content) } } } @@ -117,18 +114,10 @@ func Mailer() *template.Template { continue } - _, err = templates.New( - strings.TrimSuffix( - filePath, - ".tmpl", - ), - ).Parse(string(content)) - if err != nil { - log.Warn("Failed to parse template %v", err) - } + buildSubjectBodyTemplate(subjectTemplates, bodyTemplates, strings.TrimSuffix(filePath, ".tmpl"), content) } } } - return templates + return subjectTemplates, bodyTemplates } diff --git a/modules/templates/helper.go b/modules/templates/helper.go index 2d7a1aee9b..1347835b80 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -16,8 +16,10 @@ import ( "mime" "net/url" "path/filepath" + "regexp" "runtime" "strings" + texttmpl "text/template" "time" "unicode" @@ -34,6 +36,9 @@ import ( "github.com/editorconfig/editorconfig-core-go/v2" ) +// Used from static.go && dynamic.go +var mailSubjectSplit = regexp.MustCompile(`(?m)^-{3,}[\s]*$`) + // NewFuncMap returns functions for injecting to templates func NewFuncMap() []template.FuncMap { return []template.FuncMap{map[string]interface{}{ @@ -261,6 +266,112 @@ func NewFuncMap() []template.FuncMap { }} } +// NewTextFuncMap returns functions for injecting to text templates +// It's a subset of those used for HTML and other templates +func NewTextFuncMap() []texttmpl.FuncMap { + return []texttmpl.FuncMap{map[string]interface{}{ + "GoVer": func() string { + return strings.Title(runtime.Version()) + }, + "AppName": func() string { + return setting.AppName + }, + "AppSubUrl": func() string { + return setting.AppSubURL + }, + "AppUrl": func() string { + return setting.AppURL + }, + "AppVer": func() string { + return setting.AppVer + }, + "AppBuiltWith": func() string { + return setting.AppBuiltWith + }, + "AppDomain": func() string { + return setting.Domain + }, + "TimeSince": timeutil.TimeSince, + "TimeSinceUnix": timeutil.TimeSinceUnix, + "RawTimeSince": timeutil.RawTimeSince, + "DateFmtLong": func(t time.Time) string { + return t.Format(time.RFC1123Z) + }, + "DateFmtShort": func(t time.Time) string { + return t.Format("Jan 02, 2006") + }, + "List": List, + "SubStr": func(str string, start, length int) string { + if len(str) == 0 { + return "" + } + end := start + length + if length == -1 { + end = len(str) + } + if len(str) < end { + return str + } + return str[start:end] + }, + "EllipsisString": base.EllipsisString, + "URLJoin": util.URLJoin, + "Dict": func(values ...interface{}) (map[string]interface{}, error) { + if len(values)%2 != 0 { + return nil, errors.New("invalid dict call") + } + dict := make(map[string]interface{}, len(values)/2) + for i := 0; i < len(values); i += 2 { + key, ok := values[i].(string) + if !ok { + return nil, errors.New("dict keys must be strings") + } + dict[key] = values[i+1] + } + return dict, nil + }, + "Printf": fmt.Sprintf, + "Escape": Escape, + "Sec2Time": models.SecToTime, + "ParseDeadline": func(deadline string) []string { + return strings.Split(deadline, "|") + }, + "dict": func(values ...interface{}) (map[string]interface{}, error) { + if len(values) == 0 { + return nil, errors.New("invalid dict call") + } + + dict := make(map[string]interface{}) + + for i := 0; i < len(values); i++ { + switch key := values[i].(type) { + case string: + i++ + if i == len(values) { + return nil, errors.New("specify the key for non array values") + } + dict[key] = values[i] + case map[string]interface{}: + m := values[i].(map[string]interface{}) + for i, v := range m { + dict[i] = v + } + default: + return nil, errors.New("dict values must be maps") + } + } + return dict, nil + }, + "percentage": func(n int, values ...int) float32 { + var sum = 0 + for i := 0; i < len(values); i++ { + sum += values[i] + } + return float32(n) * 100 / float32(sum) + }, + }} +} + // Safe render raw as HTML func Safe(raw string) template.HTML { return template.HTML(raw) @@ -551,3 +662,22 @@ func MigrationIcon(hostname string) string { return "fa-git-alt" } } + +func buildSubjectBodyTemplate(stpl *texttmpl.Template, btpl *template.Template, name string, content []byte) { + // Split template into subject and body + var subjectContent []byte + bodyContent := content + loc := mailSubjectSplit.FindIndex(content) + if loc != nil { + subjectContent = content[0:loc[0]] + bodyContent = content[loc[1]:] + } + if _, err := stpl.New(name). + Parse(string(subjectContent)); err != nil { + log.Warn("Failed to parse template [%s/subject]: %v", name, err) + } + if _, err := btpl.New(name). + Parse(string(bodyContent)); err != nil { + log.Warn("Failed to parse template [%s/body]: %v", name, err) + } +} diff --git a/modules/templates/helper_test.go b/modules/templates/helper_test.go new file mode 100644 index 0000000000..e2997cb853 --- /dev/null +++ b/modules/templates/helper_test.go @@ -0,0 +1,55 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package templates + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSubjectBodySeparator(t *testing.T) { + test := func(input, subject, body string) { + loc := mailSubjectSplit.FindIndex([]byte(input)) + if loc == nil { + assert.Empty(t, subject, "no subject found, but one expected") + assert.Equal(t, body, input) + } else { + assert.Equal(t, subject, string(input[0:loc[0]])) + assert.Equal(t, body, string(input[loc[1]:])) + } + } + + test("Simple\n---------------\nCase", + "Simple\n", + "\nCase") + test("Only\nBody", + "", + "Only\nBody") + test("Minimal\n---\nseparator", + "Minimal\n", + "\nseparator") + test("False --- separator", + "", + "False --- separator") + test("False\n--- separator", + "", + "False\n--- separator") + test("False ---\nseparator", + "", + "False ---\nseparator") + test("With extra spaces\n----- \t \nBody", + "With extra spaces\n", + "\nBody") + test("With leading spaces\n -------\nOnly body", + "", + "With leading spaces\n -------\nOnly body") + test("Multiple\n---\n-------\n---\nSeparators", + "Multiple\n", + "\n-------\n---\nSeparators") + test("Insuficient\n--\nSeparators", + "", + "Insuficient\n--\nSeparators") +} diff --git a/modules/templates/static.go b/modules/templates/static.go index f7e53ce887..435ccb1f95 100644 --- a/modules/templates/static.go +++ b/modules/templates/static.go @@ -14,6 +14,7 @@ import ( "io/ioutil" "path" "strings" + texttmpl "text/template" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -23,7 +24,8 @@ import ( ) var ( - templates = template.New("") + subjectTemplates = texttmpl.New("") + bodyTemplates = template.New("") ) type templateFileSystem struct { @@ -140,9 +142,12 @@ func JSRenderer() macaron.Handler { } // Mailer provides the templates required for sending notification mails. -func Mailer() *template.Template { +func Mailer() (*texttmpl.Template, *template.Template) { + for _, funcs := range NewTextFuncMap() { + subjectTemplates.Funcs(funcs) + } for _, funcs := range NewFuncMap() { - templates.Funcs(funcs) + bodyTemplates.Funcs(funcs) } for _, assetPath := range AssetNames() { @@ -161,7 +166,8 @@ func Mailer() *template.Template { continue } - templates.New( + buildSubjectBodyTemplate(subjectTemplates, + bodyTemplates, strings.TrimPrefix( strings.TrimSuffix( assetPath, @@ -169,7 +175,7 @@ func Mailer() *template.Template { ), "mail/", ), - ).Parse(string(content)) + content) } customDir := path.Join(setting.CustomPath, "templates", "mail") @@ -192,17 +198,18 @@ func Mailer() *template.Template { continue } - templates.New( + buildSubjectBodyTemplate(subjectTemplates, + bodyTemplates, strings.TrimSuffix( filePath, ".tmpl", ), - ).Parse(string(content)) + content) } } } - return templates + return subjectTemplates, bodyTemplates } func Asset(name string) ([]byte, error) { diff --git a/options/locale/locale_de-DE.ini b/options/locale/locale_de-DE.ini index a37d9d7dd7..dc32a9f154 100644 --- a/options/locale/locale_de-DE.ini +++ b/options/locale/locale_de-DE.ini @@ -1089,6 +1089,9 @@ activity.period.daily=1 Tag activity.period.halfweekly=3 Tage activity.period.weekly=1 Woche activity.period.monthly=1 Monat +activity.period.quarterly=3 Monate +activity.period.semiyearly=6 Monate +activity.period.yearly=1 Jahr activity.overview=Übersicht activity.active_prs_count_1=<strong>%d</strong> aktiver Pull-Request activity.active_prs_count_n=<strong>%d</strong> aktive Pull-Requests @@ -1511,6 +1514,7 @@ team_name=Teamname team_desc=Beschreibung team_name_helper=Teamnamen sollten kurz und einprägsam sein. team_desc_helper=Beschreibe den Zweck oder die Rolle des Teams. +team_access_desc=Zugriff auf das Repository team_permission_desc=Berechtigungen team_unit_desc=Zugriff auf Repositorybereiche erlauben @@ -1584,6 +1588,13 @@ teams.add_nonexistent_repo=Das Repository, das du hinzufügen möchten, existier teams.add_duplicate_users=Dieser Benutzer ist bereits ein Teammitglied. teams.repos.none=Dieses Team hat Zugang zu keinem Repository. teams.members.none=Keine Mitglieder in diesem Team. +teams.specific_repositories=Bestimmte Repositories +teams.specific_repositories_helper=Mitglieder haben nur Zugriff auf Repositories, die explizit dem Team hinzugefügt wurden. Wenn Du diese Option wählst, werden Repositories, die bereits mit <i>Alle Repositories</i> hinzugefügt wurden, <strong>nicht</strong> automatisch entfernt. +teams.all_repositories=Alle Repositories +teams.all_repositories_helper=Team hat Zugriff auf alle Repositorys. Wenn dies ausgewählt wird, werden <strong>alle vorhandenen</strong> Repositories zum Team hinzugefügt. +teams.all_repositories_read_permission_desc=Dieses Team gewährt <strong>Lese</strong>-Zugriff auf <strong>Repositories</strong>: Mitglieder können Repositories ansehen und klonen. +teams.all_repositories_write_permission_desc=Dieses Team gewährt <strong>Schreib</strong>-Zugriff auf <strong>alle Repositories</strong>: Mitglieder können Repositories lesen und auf sie pushen. +teams.all_repositories_admin_permission_desc=Dieses Team gewährt <strong>Administrator</strong>-Zugriff auf <strong> alle Repositories </strong>: Mitglieder können Repositories lesen, auf sie pushen und Mitwirkende zu Repositories hinzufügen. [admin] dashboard=Dashboard diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index db28fcede0..932d0bceac 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1515,6 +1515,7 @@ team_name = Team Name team_desc = Description team_name_helper = Team names should be short and memorable. team_desc_helper = Describe the purpose or role of the team. +team_access_desc = Repository access team_permission_desc = Permission team_unit_desc = Allow Access to Repository Sections @@ -1588,6 +1589,13 @@ teams.add_nonexistent_repo = "The repository you're trying to add does not exist teams.add_duplicate_users = User is already a team member. teams.repos.none = No repositories could be accessed by this team. teams.members.none = No members on this team. +teams.specific_repositories = Specific repositories +teams.specific_repositories_helper = Members will only have access to repositories explicitly added to the team. Selecting this <strong>will not</strong> automatically remove repositories already added with <i>All repositories</i>. +teams.all_repositories = All repositories +teams.all_repositories_helper = Team has access to all repositories. Selecting this will <strong>add all existing</strong> repositories to the team. +teams.all_repositories_read_permission_desc = This team grants <strong>Read</strong> access to <strong>all repositories</strong>: members can view and clone repositories. +teams.all_repositories_write_permission_desc = This team grants <strong>Write</strong> access to <strong>all repositories</strong>: members can read from and push to repositories. +teams.all_repositories_admin_permission_desc = This team grants <strong>Admin</strong> access to <strong>all repositories</strong>: members can read from, push to and add collaborators to repositories. [admin] dashboard = Dashboard diff --git a/options/locale/locale_fr-FR.ini b/options/locale/locale_fr-FR.ini index c9a8711533..c1509b1089 100644 --- a/options/locale/locale_fr-FR.ini +++ b/options/locale/locale_fr-FR.ini @@ -586,6 +586,8 @@ fork_visibility_helper=La visibilité d'un dépôt bifurqué ne peut pas être m repo_desc=Description repo_lang=Langue repo_gitignore_helper=Choisissez un modèle de fichier .gitignore. +issue_labels=Étiquettes des tickets +issue_labels_helper=Sélectionnez une étiquette de ticket. license=Licence license_helper=Sélectionner un fichier de licence. readme=LISEZMOI @@ -846,6 +848,8 @@ issues.create_comment=Créer un commentaire issues.closed_at=`a fermé <a id="%[1]s"href="#%[1]s"> %[2]s</a>` issues.reopened_at=`réouvert à <a id="%[1]s" href="#%[1]s"> %[2]s</a>` issues.commit_ref_at=`a référencé ce ticket depuis une révision <a id="%[1]s" href="#%[1]s"> %[2]s</a>` +issues.ref_issue_at=`a fait référence à ce ticket : %[1]s` +issues.ref_issue_ext_at=`a fait référence à ce ticket depuis : %[1]s %[2]s` issues.poster=Publier issues.collaborator=Collaborateur issues.owner=Propriétaire @@ -1330,6 +1334,8 @@ settings.protect_merge_whitelist_committers_desc=N'autoriser que les utilisateur settings.protect_merge_whitelist_users=Utilisateurs en liste blanche de fusion : settings.protect_merge_whitelist_teams=Équipes en liste blanche de fusion : settings.protect_check_status_contexts=Activer le Contrôle Qualité +settings.protect_check_status_contexts_desc=Exiger le passage du contrôle qualité avant de fusionner Choisir quels contrôles qualité doivent être validés avant que les branches puissent être fusionnées dans une branche qui correspond à cette règle. Si activé, les commits doivent d'abord être poussés vers une autre branche avant d'être fusionnés ou bien poussés directement vers une branche qui correspond à cette règle après que les contrôles qualité soient passés. Si aucun contexte n'a été choisi, le dernier commit doit passer le contrôle qualité peu-importe le contexte. +settings.protect_check_status_contexts_list=Contrôles qualité trouvés au cours de la semaine dernière pour ce dépôt settings.protect_required_approvals=Agréments nécessaires : settings.protect_required_approvals_desc=N'autoriser la fusion qu'avec suffisamment de revues positives d'utilisateurs ou équipes sur liste blanche. settings.protect_approvals_whitelist_users=Réviseurs sur liste blanche : @@ -1366,6 +1372,10 @@ diff.parent=Parent diff.commit=révision diff.git-notes=Notes diff.data_not_available=Contenu de la comparaison indisponible +diff.options_button=Option de Diff +diff.show_diff_stats=Voir les Statistiques +diff.download_patch=Télécharger le Fichier Patch +diff.download_diff=Télécharger le Fichier des Différences diff.show_split_view=Vue séparée diff.show_unified_view=Vue unifiée diff.whitespace_button=Espace @@ -1376,6 +1386,11 @@ diff.whitespace_ignore_at_eol=Ignorer les changements quand ce sont des espaces diff.stats_desc=<strong> %d fichiers modifiés</strong> avec <strong>%d ajouts</strong> et <strong>%d suppressions</strong> diff.bin=BIN diff.view_file=Voir le fichier +diff.file_before=Avant +diff.file_after=Après +diff.file_image_width=Largeur +diff.file_image_height=Hauteur +diff.file_byte_size=Taille diff.file_suppressed=Fichier diff supprimé car celui-ci est trop grand diff.too_many_files=Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff diff.comment.placeholder=Laisser un commentaire @@ -1443,6 +1458,8 @@ branch.restore_failed=La restauration de la branche '%s' a échoué. branch.protected_deletion_failed=La branche '%s' est protégé. Il ne peut pas être supprimé. branch.restore=Restaurer la branche '%s' branch.download=Télécharger la branche '%s' +branch.included_desc=Cette branche fait partie de la branche par défaut +branch.included=Incluses topic.manage_topics=Gérer les sujets topic.done=Terminé @@ -1478,6 +1495,8 @@ settings.options=Organisation settings.full_name=Non Complet settings.website=Site Web settings.location=Localisation +settings.permission=Autorisations +settings.repoadminchangeteam=L'administrateur de dépôt peut ajouter et supprimer l'accès aux équipes settings.visibility=Visibilité settings.visibility.public=Public settings.visibility.limited=Limité (Visible uniquement aux utilisateurs connectés) @@ -1724,6 +1743,7 @@ auths.tip.google_plus=Obtenez des identifiants OAuth2 sur la console API de Goog auths.tip.openid_connect=Utilisez l'URL de découvert OpenID (<server>/.well-known/openid-configuration) pour spécifier les points d'accès auths.tip.twitter=Rendez-vous sur https://dev.twitter.com/apps, créez une application et assurez-vous que l'option "Autoriser l'application à être utilisée avec Twitter Connect" est activée auths.tip.discord=Enregistrer une nouvelle application sur https://discordapp.com/developers/applications/me +auths.tip.gitea=Enregistrez une nouvelle application OAuth2. Un guide peut être trouvé sur https://docs.gitea.io/en-us/oauth2-provider/ auths.edit=Mettre à jour la source d'authentification auths.activated=Cette source d'authentification est activée auths.new_success=L'authentification "%s" a été ajoutée. @@ -1956,6 +1976,7 @@ mark_as_unread=Marquer comme non lue mark_all_as_read=Tout marquer comme lu [gpg] +default_key=Signé avec la clé par défaut error.extract_sign=Impossible d'extraire la signature error.generate_hash=Impossible de générer la chaine de hachage de la révision error.no_committer_account=Aucun compte lié à l'adresse e-mail de l'auteur diff --git a/options/locale/locale_pl-PL.ini b/options/locale/locale_pl-PL.ini index 50eed5558d..71c9522921 100644 --- a/options/locale/locale_pl-PL.ini +++ b/options/locale/locale_pl-PL.ini @@ -74,6 +74,7 @@ preview=Podgląd loading=Ładowanie… [startpage] +app_desc=Bezbolesna usługa Git na własnym serwerze [install] install=Instalacja @@ -282,9 +283,9 @@ AuthName=Nazwa autoryzacji AdminEmail=E-mail administratora NewBranchName=Nazwa nowej gałęzi -CommitSummary=Podsumowanie commitu -CommitMessage=Wiadomość commitu -CommitChoice=Wybór commitu +CommitSummary=Podsumowanie commita +CommitMessage=Wiadomość commita +CommitChoice=Wybór commita TreeName=Ścieżka pliku Content=Treść diff --git a/options/locale/locale_pt-BR.ini b/options/locale/locale_pt-BR.ini index df37b23357..33da0ed0c4 100644 --- a/options/locale/locale_pt-BR.ini +++ b/options/locale/locale_pt-BR.ini @@ -1514,6 +1514,7 @@ team_name=Nome da equipe team_desc=Descrição team_name_helper=Nomes de equipe devem ser curtos e memoráveis. team_desc_helper=Descreva a finalidade ou o papel da equipe. +team_access_desc=Acesso ao repositório team_permission_desc=Permissão team_unit_desc=Permitir o acesso a seções de repositório @@ -1587,6 +1588,13 @@ teams.add_nonexistent_repo=O repositório que você está tentando adicionar nã teams.add_duplicate_users=Usuário já é um membro da equipe. teams.repos.none=Nenhum repositório pode ser acessado por essa equipe. teams.members.none=Nenhum membro nesta equipe. +teams.specific_repositories=Repositórios específicos +teams.specific_repositories_helper=Os membros terão acesso apenas aos repositórios explicitamente adicionados à equipe. Selecionar este <strong>não</strong> removerá automaticamente os repositórios já adicionados com <i>Todos os repositórios</i>. +teams.all_repositories=Todos os repositórios +teams.all_repositories_helper=A equipe tem acesso a todos os repositórios. Selecionar isto irá <strong>adicionar todos os repositórios existentes</strong> à equipe. +teams.all_repositories_read_permission_desc=Esta equipe concede acesso <strong>Leitura</strong> a <strong>todos os repositórios</strong>: membros podem ver e clonar repositórios. +teams.all_repositories_write_permission_desc=Esta equipe concede acesso <strong>Escrita</strong> a <strong>todos os repositórios</strong>: os membros podem ler de e fazer push para os repositórios. +teams.all_repositories_admin_permission_desc=Esta equipe concede acesso <strong>Administrativo</strong> a <strong>todos os repositórios</strong>: os membros podem ler, fazer push e adicionar colaboradores aos repositórios. [admin] dashboard=Painel diff --git a/public/css/index.css b/public/css/index.css index f7eb02b296..3ec47eb85c 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -818,6 +818,7 @@ i.icon.centerlock{top:1.5em} .issue.list>.item .desc .checklist{padding-left:5px} .issue.list>.item .desc .checklist .progress-bar{margin-left:2px;width:80px;height:6px;display:inline-block;background-color:#eee;overflow:hidden;border-radius:3px;vertical-align:2px!important} .issue.list>.item .desc .checklist .progress-bar .progress{background-color:#ccc;display:block;height:100%} +.issue.list>.item .desc .due-date{padding-left:5px} .issue.list>.item .desc a.milestone{margin-left:5px;color:#999!important} .issue.list>.item .desc a.milestone:hover{color:#000!important} .issue.list>.item .desc a.ref{margin-left:8px;color:#999!important} diff --git a/public/css/theme-arc-green.css b/public/css/theme-arc-green.css index 28a127a3dd..74a7c3ddc3 100644 --- a/public/css/theme-arc-green.css +++ b/public/css/theme-arc-green.css @@ -249,6 +249,11 @@ a.ui.label:hover,a.ui.labels .label:hover{background-color:#505667!important;col .xdsoft_datetimepicker .xdsoft_datepicker .xdsoft_calendar td,.xdsoft_datetimepicker .xdsoft_datepicker .xdsoft_calendar th{border-color:#4c505c;background-color:#2a2e39} .xdsoft_datetimepicker .xdsoft_datepicker .xdsoft_calendar td.xdsoft_disabled,.xdsoft_datetimepicker .xdsoft_datepicker .xdsoft_calendar td.xdsoft_other_month{opacity:.8;background:#a0cc75;color:#000} .heatmap-color-0{background-color:#2d303b} +.heatmap-color-1{background-color:#444f47} +.heatmap-color-2{background-color:#5b6e52} +.heatmap-color-3{background-color:#728e5e} +.heatmap-color-4{background-color:#89ad69} +.heatmap-color-5{background-color:#a0cc75} .CodeMirror{color:#9daccc;background-color:#2b2b2b;border-top:0} .CodeMirror div.CodeMirror-cursor{border-left:1px solid #9e9e9e} .CodeMirror .CodeMirror-gutters{background-color:#2b2b2b} diff --git a/public/less/_repository.less b/public/less/_repository.less index d585eb03a6..84d59bbe91 100644 --- a/public/less/_repository.less +++ b/public/less/_repository.less @@ -2073,6 +2073,10 @@ } } + .due-date { + padding-left: 5px; + } + a.milestone { margin-left: 5px; color: #999999 !important; diff --git a/public/less/themes/arc-green.less b/public/less/themes/arc-green.less index 27c32728a2..06e7c78d41 100644 --- a/public/less/themes/arc-green.less +++ b/public/less/themes/arc-green.less @@ -1294,8 +1294,34 @@ a.ui.labels .label:hover { } } +.heatmap(@heat) { + @heatmap-cold: #2d303b; + @heatmap-hot: #a0cc75; + background-color: mix(@heatmap-hot, @heatmap-cold, @heat); +} + .heatmap-color-0 { - background-color: #2d303b; + .heatmap(0%); +} + +.heatmap-color-1 { + .heatmap(20%); +} + +.heatmap-color-2 { + .heatmap(40%); +} + +.heatmap-color-3 { + .heatmap(60%); +} + +.heatmap-color-4 { + .heatmap(80%); +} + +.heatmap-color-5 { + .heatmap(100%); } /* code mirror dark theme */ diff --git a/routers/api/v1/convert/convert.go b/routers/api/v1/convert/convert.go index 6da53d6275..f52ed63476 100644 --- a/routers/api/v1/convert/convert.go +++ b/routers/api/v1/convert/convert.go @@ -227,11 +227,12 @@ func ToOrganization(org *models.User) *api.Organization { // ToTeam convert models.Team to api.Team func ToTeam(team *models.Team) *api.Team { return &api.Team{ - ID: team.ID, - Name: team.Name, - Description: team.Description, - Permission: team.Authorize.String(), - Units: team.GetUnitNames(), + ID: team.ID, + Name: team.Name, + Description: team.Description, + IncludesAllRepositories: team.IncludesAllRepositories, + Permission: team.Authorize.String(), + Units: team.GetUnitNames(), } } diff --git a/routers/api/v1/org/team.go b/routers/api/v1/org/team.go index d01f051626..a22b60a2c6 100644 --- a/routers/api/v1/org/team.go +++ b/routers/api/v1/org/team.go @@ -128,10 +128,11 @@ func CreateTeam(ctx *context.APIContext, form api.CreateTeamOption) { // "201": // "$ref": "#/responses/Team" team := &models.Team{ - OrgID: ctx.Org.Organization.ID, - Name: form.Name, - Description: form.Description, - Authorize: models.ParseAccessMode(form.Permission), + OrgID: ctx.Org.Organization.ID, + Name: form.Name, + Description: form.Description, + IncludesAllRepositories: form.IncludesAllRepositories, + Authorize: models.ParseAccessMode(form.Permission), } unitTypes := models.FindUnitTypes(form.Units...) @@ -182,11 +183,27 @@ func EditTeam(ctx *context.APIContext, form api.EditTeamOption) { // "200": // "$ref": "#/responses/Team" team := ctx.Org.Team - team.Name = form.Name team.Description = form.Description - team.Authorize = models.ParseAccessMode(form.Permission) unitTypes := models.FindUnitTypes(form.Units...) + isAuthChanged := false + isIncludeAllChanged := false + if !team.IsOwnerTeam() { + // Validate permission level. + auth := models.ParseAccessMode(form.Permission) + + team.Name = form.Name + if team.Authorize != auth { + isAuthChanged = true + team.Authorize = auth + } + + if team.IncludesAllRepositories != form.IncludesAllRepositories { + isIncludeAllChanged = true + team.IncludesAllRepositories = form.IncludesAllRepositories + } + } + if team.Authorize < models.AccessModeOwner { var units = make([]*models.TeamUnit, 0, len(form.Units)) for _, tp := range unitTypes { @@ -198,7 +215,7 @@ func EditTeam(ctx *context.APIContext, form api.EditTeamOption) { team.Units = units } - if err := models.UpdateTeam(team, true); err != nil { + if err := models.UpdateTeam(team, isAuthChanged, isIncludeAllChanged); err != nil { ctx.Error(500, "EditTeam", err) return } diff --git a/routers/org/teams.go b/routers/org/teams.go index 7ead6ea5ff..24612459a4 100644 --- a/routers/org/teams.go +++ b/routers/org/teams.go @@ -1,4 +1,5 @@ // Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. @@ -180,12 +181,14 @@ func NewTeamPost(ctx *context.Context, form auth.CreateTeamForm) { ctx.Data["PageIsOrgTeams"] = true ctx.Data["PageIsOrgTeamsNew"] = true ctx.Data["Units"] = models.Units + var includesAllRepositories = (form.RepoAccess == "all") t := &models.Team{ - OrgID: ctx.Org.Organization.ID, - Name: form.TeamName, - Description: form.Description, - Authorize: models.ParseAccessMode(form.Permission), + OrgID: ctx.Org.Organization.ID, + Name: form.TeamName, + Description: form.Description, + Authorize: models.ParseAccessMode(form.Permission), + IncludesAllRepositories: includesAllRepositories, } if t.Authorize < models.AccessModeOwner { @@ -268,6 +271,8 @@ func EditTeamPost(ctx *context.Context, form auth.CreateTeamForm) { ctx.Data["Units"] = models.Units isAuthChanged := false + isIncludeAllChanged := false + var includesAllRepositories = (form.RepoAccess == "all") if !t.IsOwnerTeam() { // Validate permission level. auth := models.ParseAccessMode(form.Permission) @@ -277,6 +282,11 @@ func EditTeamPost(ctx *context.Context, form auth.CreateTeamForm) { isAuthChanged = true t.Authorize = auth } + + if t.IncludesAllRepositories != includesAllRepositories { + isIncludeAllChanged = true + t.IncludesAllRepositories = includesAllRepositories + } } t.Description = form.Description if t.Authorize < models.AccessModeOwner { @@ -305,7 +315,7 @@ func EditTeamPost(ctx *context.Context, form auth.CreateTeamForm) { return } - if err := models.UpdateTeam(t, isAuthChanged); err != nil { + if err := models.UpdateTeam(t, isAuthChanged, isIncludeAllChanged); err != nil { ctx.Data["Err_TeamName"] = true switch { case models.IsErrTeamAlreadyExist(err): diff --git a/routers/repo/commit.go b/routers/repo/commit.go index 550e4c3a9c..f067729ca9 100644 --- a/routers/repo/commit.go +++ b/routers/repo/commit.go @@ -91,6 +91,12 @@ func Graph(ctx *context.Context) { return } + allCommitsCount, err := ctx.Repo.GitRepo.GetAllCommitsCount() + if err != nil { + ctx.ServerError("GetAllCommitsCount", err) + return + } + page := ctx.QueryInt("page") graph, err := models.GetCommitGraph(ctx.Repo.GitRepo, page) @@ -105,7 +111,7 @@ func Graph(ctx *context.Context) { ctx.Data["CommitCount"] = commitsCount ctx.Data["Branch"] = ctx.Repo.BranchName ctx.Data["RequireGitGraph"] = true - ctx.Data["Page"] = context.NewPagination(int(commitsCount), setting.UI.GraphMaxCommitNum, page, 5) + ctx.Data["Page"] = context.NewPagination(int(allCommitsCount), setting.UI.GraphMaxCommitNum, page, 5) ctx.HTML(200, tplGraph) } diff --git a/services/mailer/mail.go b/services/mailer/mail.go index bc2aff7314..fc892f6076 100644 --- a/services/mailer/mail.go +++ b/services/mailer/mail.go @@ -9,7 +9,11 @@ import ( "bytes" "fmt" "html/template" + "mime" "path" + "regexp" + "strings" + texttmpl "text/template" "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/base" @@ -28,18 +32,22 @@ const ( mailAuthResetPassword base.TplName = "auth/reset_passwd" mailAuthRegisterNotify base.TplName = "auth/register_notify" - mailIssueComment base.TplName = "issue/comment" - mailIssueMention base.TplName = "issue/mention" - mailIssueAssigned base.TplName = "issue/assigned" - mailNotifyCollaborator base.TplName = "notify/collaborator" + + // There's no actual limit for subject in RFC 5322 + mailMaxSubjectRunes = 256 ) -var templates *template.Template +var ( + bodyTemplates *template.Template + subjectTemplates *texttmpl.Template + subjectRemoveSpaces = regexp.MustCompile(`[\s]+`) +) // InitMailRender initializes the mail renderer -func InitMailRender(tmpls *template.Template) { - templates = tmpls +func InitMailRender(subjectTpl *texttmpl.Template, bodyTpl *template.Template) { + subjectTemplates = subjectTpl + bodyTemplates = bodyTpl } // SendTestMail sends a test mail @@ -58,7 +66,7 @@ func SendUserMail(language string, u *models.User, tpl base.TplName, code, subje var content bytes.Buffer - if err := templates.ExecuteTemplate(&content, string(tpl), data); err != nil { + if err := bodyTemplates.ExecuteTemplate(&content, string(tpl), data); err != nil { log.Error("Template: %v", err) return } @@ -96,7 +104,7 @@ func SendActivateEmailMail(locale Locale, u *models.User, email *models.EmailAdd var content bytes.Buffer - if err := templates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil { + if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil { log.Error("Template: %v", err) return } @@ -121,7 +129,7 @@ func SendRegisterNotifyMail(locale Locale, u *models.User) { var content bytes.Buffer - if err := templates.ExecuteTemplate(&content, string(mailAuthRegisterNotify), data); err != nil { + if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthRegisterNotify), data); err != nil { log.Error("Template: %v", err) return } @@ -145,7 +153,7 @@ func SendCollaboratorMail(u, doer *models.User, repo *models.Repository) { var content bytes.Buffer - if err := templates.ExecuteTemplate(&content, string(mailNotifyCollaborator), data); err != nil { + if err := bodyTemplates.ExecuteTemplate(&content, string(mailNotifyCollaborator), data); err != nil { log.Error("Template: %v", err) return } @@ -156,40 +164,70 @@ func SendCollaboratorMail(u, doer *models.User, repo *models.Repository) { SendAsync(msg) } -func composeTplData(subject, body, link string) map[string]interface{} { - data := make(map[string]interface{}, 10) - data["Subject"] = subject - data["Body"] = body - data["Link"] = link - return data -} +func composeIssueCommentMessage(issue *models.Issue, doer *models.User, actionType models.ActionType, fromMention bool, + content string, comment *models.Comment, tos []string, info string) *Message { -func composeIssueCommentMessage(issue *models.Issue, doer *models.User, content string, comment *models.Comment, tplName base.TplName, tos []string, info string) *Message { - var subject string + if err := issue.LoadPullRequest(); err != nil { + log.Error("LoadPullRequest: %v", err) + return nil + } + + var ( + subject string + link string + prefix string + // Fall back subject for bad templates, make sure subject is never empty + fallback string + ) + + commentType := models.CommentTypeComment if comment != nil { - subject = "Re: " + mailSubject(issue) + prefix = "Re: " + commentType = comment.Type + link = issue.HTMLURL() + "#" + comment.HashTag() } else { - subject = mailSubject(issue) - } - err := issue.LoadRepo() - if err != nil { - log.Error("LoadRepo: %v", err) + link = issue.HTMLURL() } + + fallback = prefix + fallbackMailSubject(issue) + + // This is the body of the new issue or comment, not the mail body body := string(markup.RenderByType(markdown.MarkupName, []byte(content), issue.Repo.HTMLURL(), issue.Repo.ComposeMetas())) - var data = make(map[string]interface{}, 10) - if comment != nil { - data = composeTplData(subject, body, issue.HTMLURL()+"#"+comment.HashTag()) - } else { - data = composeTplData(subject, body, issue.HTMLURL()) + actType, actName, tplName := actionToTemplate(issue, actionType, commentType) + + mailMeta := map[string]interface{}{ + "FallbackSubject": fallback, + "Body": body, + "Link": link, + "Issue": issue, + "Comment": comment, + "IsPull": issue.IsPull, + "User": issue.Repo.MustOwner(), + "Repo": issue.Repo.FullName(), + "Doer": doer, + "IsMention": fromMention, + "SubjectPrefix": prefix, + "ActionType": actType, + "ActionName": actName, } - data["Doer"] = doer - data["Issue"] = issue + + var mailSubject bytes.Buffer + if err := subjectTemplates.ExecuteTemplate(&mailSubject, string(tplName), mailMeta); err == nil { + subject = sanitizeSubject(mailSubject.String()) + } else { + log.Error("ExecuteTemplate [%s]: %v", string(tplName)+"/subject", err) + } + + if subject == "" { + subject = fallback + } + mailMeta["Subject"] = subject var mailBody bytes.Buffer - if err := templates.ExecuteTemplate(&mailBody, string(tplName), data); err != nil { - log.Error("Template: %v", err) + if err := bodyTemplates.ExecuteTemplate(&mailBody, string(tplName), mailMeta); err != nil { + log.Error("ExecuteTemplate [%s]: %v", string(tplName)+"/body", err) } msg := NewMessageFrom(tos, doer.DisplayName(), setting.MailService.FromEmail, subject, mailBody.String()) @@ -206,24 +244,81 @@ func composeIssueCommentMessage(issue *models.Issue, doer *models.User, content return msg } +func sanitizeSubject(subject string) string { + runes := []rune(strings.TrimSpace(subjectRemoveSpaces.ReplaceAllLiteralString(subject, " "))) + if len(runes) > mailMaxSubjectRunes { + runes = runes[:mailMaxSubjectRunes] + } + // Encode non-ASCII characters + return mime.QEncoding.Encode("utf-8", string(runes)) +} + // SendIssueCommentMail composes and sends issue comment emails to target receivers. -func SendIssueCommentMail(issue *models.Issue, doer *models.User, content string, comment *models.Comment, tos []string) { +func SendIssueCommentMail(issue *models.Issue, doer *models.User, actionType models.ActionType, content string, comment *models.Comment, tos []string) { if len(tos) == 0 { return } - SendAsync(composeIssueCommentMessage(issue, doer, content, comment, mailIssueComment, tos, "issue comment")) + SendAsync(composeIssueCommentMessage(issue, doer, actionType, false, content, comment, tos, "issue comment")) } // SendIssueMentionMail composes and sends issue mention emails to target receivers. -func SendIssueMentionMail(issue *models.Issue, doer *models.User, content string, comment *models.Comment, tos []string) { +func SendIssueMentionMail(issue *models.Issue, doer *models.User, actionType models.ActionType, content string, comment *models.Comment, tos []string) { if len(tos) == 0 { return } - SendAsync(composeIssueCommentMessage(issue, doer, content, comment, mailIssueMention, tos, "issue mention")) + SendAsync(composeIssueCommentMessage(issue, doer, actionType, true, content, comment, tos, "issue mention")) +} + +// actionToTemplate returns the type and name of the action facing the user +// (slightly different from models.ActionType) and the name of the template to use (based on availability) +func actionToTemplate(issue *models.Issue, actionType models.ActionType, commentType models.CommentType) (typeName, name, template string) { + if issue.IsPull { + typeName = "pull" + } else { + typeName = "issue" + } + switch actionType { + case models.ActionCreateIssue, models.ActionCreatePullRequest: + name = "new" + case models.ActionCommentIssue: + name = "comment" + case models.ActionCloseIssue, models.ActionClosePullRequest: + name = "close" + case models.ActionReopenIssue, models.ActionReopenPullRequest: + name = "reopen" + case models.ActionMergePullRequest: + name = "merge" + default: + switch commentType { + case models.CommentTypeReview: + name = "review" + case models.CommentTypeCode: + name = "code" + case models.CommentTypeAssignees: + name = "assigned" + default: + name = "default" + } + } + + template = typeName + "/" + name + ok := bodyTemplates.Lookup(template) != nil + if !ok && typeName != "issue" { + template = "issue/" + name + ok = bodyTemplates.Lookup(template) != nil + } + if !ok { + template = typeName + "/default" + ok = bodyTemplates.Lookup(template) != nil + } + if !ok { + template = "issue/default" + } + return } // SendIssueAssignedMail composes and sends issue assigned email func SendIssueAssignedMail(issue *models.Issue, doer *models.User, content string, comment *models.Comment, tos []string) { - SendAsync(composeIssueCommentMessage(issue, doer, content, comment, mailIssueAssigned, tos, "issue assigned")) + SendAsync(composeIssueCommentMessage(issue, doer, models.ActionType(0), false, content, comment, tos, "issue assigned")) } diff --git a/services/mailer/mail_comment.go b/services/mailer/mail_comment.go index d306c14f42..6469eb1fa1 100644 --- a/services/mailer/mail_comment.go +++ b/services/mailer/mail_comment.go @@ -31,24 +31,8 @@ func mailParticipantsComment(ctx models.DBContext, c *models.Comment, opType mod for i, u := range userMentions { mentions[i] = u.LowerName } - if len(c.Content) > 0 { - if err = mailIssueCommentToParticipants(issue, c.Poster, c.Content, c, mentions); err != nil { - log.Error("mailIssueCommentToParticipants: %v", err) - } + if err = mailIssueCommentToParticipants(issue, c.Poster, opType, c.Content, c, mentions); err != nil { + log.Error("mailIssueCommentToParticipants: %v", err) } - - switch opType { - case models.ActionCloseIssue: - ct := fmt.Sprintf("Closed #%d.", issue.Index) - if err = mailIssueCommentToParticipants(issue, c.Poster, ct, c, mentions); err != nil { - log.Error("mailIssueCommentToParticipants: %v", err) - } - case models.ActionReopenIssue: - ct := fmt.Sprintf("Reopened #%d.", issue.Index) - if err = mailIssueCommentToParticipants(issue, c.Poster, ct, c, mentions); err != nil { - log.Error("mailIssueCommentToParticipants: %v", err) - } - } - return nil } diff --git a/services/mailer/mail_issue.go b/services/mailer/mail_issue.go index a5f3251807..32b21b1324 100644 --- a/services/mailer/mail_issue.go +++ b/services/mailer/mail_issue.go @@ -14,7 +14,7 @@ import ( "github.com/unknwon/com" ) -func mailSubject(issue *models.Issue) string { +func fallbackMailSubject(issue *models.Issue) string { return fmt.Sprintf("[%s] %s (#%d)", issue.Repo.FullName(), issue.Title, issue.Index) } @@ -22,7 +22,7 @@ func mailSubject(issue *models.Issue) string { // This function sends two list of emails: // 1. Repository watchers and users who are participated in comments. // 2. Users who are not in 1. but get mentioned in current issue/comment. -func mailIssueCommentToParticipants(issue *models.Issue, doer *models.User, content string, comment *models.Comment, mentions []string) error { +func mailIssueCommentToParticipants(issue *models.Issue, doer *models.User, actionType models.ActionType, content string, comment *models.Comment, mentions []string) error { watchers, err := models.GetWatchers(issue.RepoID) if err != nil { @@ -89,7 +89,7 @@ func mailIssueCommentToParticipants(issue *models.Issue, doer *models.User, cont } for _, to := range tos { - SendIssueCommentMail(issue, doer, content, comment, []string{to}) + SendIssueCommentMail(issue, doer, actionType, content, comment, []string{to}) } // Mail mentioned people and exclude watchers. @@ -106,7 +106,7 @@ func mailIssueCommentToParticipants(issue *models.Issue, doer *models.User, cont emails := models.GetUserEmailsByNames(tos) for _, to := range emails { - SendIssueMentionMail(issue, doer, content, comment, []string{to}) + SendIssueMentionMail(issue, doer, actionType, content, comment, []string{to}) } return nil @@ -131,32 +131,8 @@ func mailParticipants(ctx models.DBContext, issue *models.Issue, doer *models.Us for i, u := range userMentions { mentions[i] = u.LowerName } - - if len(issue.Content) > 0 { - if err = mailIssueCommentToParticipants(issue, doer, issue.Content, nil, mentions); err != nil { - log.Error("mailIssueCommentToParticipants: %v", err) - } + if err = mailIssueCommentToParticipants(issue, doer, opType, issue.Content, nil, mentions); err != nil { + log.Error("mailIssueCommentToParticipants: %v", err) } - - switch opType { - case models.ActionCreateIssue, models.ActionCreatePullRequest: - if len(issue.Content) == 0 { - ct := fmt.Sprintf("Created #%d.", issue.Index) - if err = mailIssueCommentToParticipants(issue, doer, ct, nil, mentions); err != nil { - log.Error("mailIssueCommentToParticipants: %v", err) - } - } - case models.ActionCloseIssue, models.ActionClosePullRequest: - ct := fmt.Sprintf("Closed #%d.", issue.Index) - if err = mailIssueCommentToParticipants(issue, doer, ct, nil, mentions); err != nil { - log.Error("mailIssueCommentToParticipants: %v", err) - } - case models.ActionReopenIssue, models.ActionReopenPullRequest: - ct := fmt.Sprintf("Reopened #%d.", issue.Index) - if err = mailIssueCommentToParticipants(issue, doer, ct, nil, mentions); err != nil { - log.Error("mailIssueCommentToParticipants: %v", err) - } - } - return nil } diff --git a/services/mailer/mail_test.go b/services/mailer/mail_test.go index c7a84d6b33..a10507e0e4 100644 --- a/services/mailer/mail_test.go +++ b/services/mailer/mail_test.go @@ -5,8 +5,10 @@ package mailer import ( + "bytes" "html/template" "testing" + texttmpl "text/template" "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/setting" @@ -14,7 +16,11 @@ import ( "github.com/stretchr/testify/assert" ) -const tmpl = ` +const subjectTpl = ` +{{.SubjectPrefix}}[{{.Repo}}] @{{.Doer.Name}} #{{.Issue.Index}} - {{.Issue.Title}} +` + +const bodyTpl = ` <!DOCTYPE html> <html> <head> @@ -47,17 +53,19 @@ func TestComposeIssueCommentMessage(t *testing.T) { issue := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 1, Repo: repo, Poster: doer}).(*models.Issue) comment := models.AssertExistsAndLoadBean(t, &models.Comment{ID: 2, Issue: issue}).(*models.Comment) - email := template.Must(template.New("issue/comment").Parse(tmpl)) - InitMailRender(email) + stpl := texttmpl.Must(texttmpl.New("issue/comment").Parse(subjectTpl)) + btpl := template.Must(template.New("issue/comment").Parse(bodyTpl)) + InitMailRender(stpl, btpl) tos := []string{"test@gitea.com", "test2@gitea.com"} - msg := composeIssueCommentMessage(issue, doer, "test body", comment, mailIssueComment, tos, "issue comment") + msg := composeIssueCommentMessage(issue, doer, models.ActionCommentIssue, false, "test body", comment, tos, "issue comment") subject := msg.GetHeader("Subject") inreplyTo := msg.GetHeader("In-Reply-To") references := msg.GetHeader("References") - assert.Equal(t, subject[0], "Re: "+mailSubject(issue), "Comment reply subject should contain Re:") + assert.Equal(t, "Re: ", subject[0][:4], "Comment reply subject should contain Re:") + assert.Equal(t, "Re: [user2/repo1] @user2 #1 - issue1", subject[0]) assert.Equal(t, inreplyTo[0], "<user2/repo1/issues/1@localhost>", "In-Reply-To header doesn't match") assert.Equal(t, references[0], "<user2/repo1/issues/1@localhost>", "References header doesn't match") } @@ -75,17 +83,122 @@ func TestComposeIssueMessage(t *testing.T) { repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1, Owner: doer}).(*models.Repository) issue := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 1, Repo: repo, Poster: doer}).(*models.Issue) - email := template.Must(template.New("issue/comment").Parse(tmpl)) - InitMailRender(email) + stpl := texttmpl.Must(texttmpl.New("issue/new").Parse(subjectTpl)) + btpl := template.Must(template.New("issue/new").Parse(bodyTpl)) + InitMailRender(stpl, btpl) tos := []string{"test@gitea.com", "test2@gitea.com"} - msg := composeIssueCommentMessage(issue, doer, "test body", nil, mailIssueComment, tos, "issue create") + msg := composeIssueCommentMessage(issue, doer, models.ActionCreateIssue, false, "test body", nil, tos, "issue create") subject := msg.GetHeader("Subject") messageID := msg.GetHeader("Message-ID") - assert.Equal(t, subject[0], mailSubject(issue), "Subject not equal to issue.mailSubject()") + assert.Equal(t, "[user2/repo1] @user2 #1 - issue1", subject[0]) assert.Nil(t, msg.GetHeader("In-Reply-To")) assert.Nil(t, msg.GetHeader("References")) assert.Equal(t, messageID[0], "<user2/repo1/issues/1@localhost>", "Message-ID header doesn't match") } + +func TestTemplateSelection(t *testing.T) { + assert.NoError(t, models.PrepareTestDatabase()) + var mailService = setting.Mailer{ + From: "test@gitea.com", + } + + setting.MailService = &mailService + setting.Domain = "localhost" + + doer := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User) + repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1, Owner: doer}).(*models.Repository) + issue := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 1, Repo: repo, Poster: doer}).(*models.Issue) + tos := []string{"test@gitea.com"} + + stpl := texttmpl.Must(texttmpl.New("issue/default").Parse("issue/default/subject")) + texttmpl.Must(stpl.New("issue/new").Parse("issue/new/subject")) + texttmpl.Must(stpl.New("pull/comment").Parse("pull/comment/subject")) + texttmpl.Must(stpl.New("issue/close").Parse("")) // Must default to fallback subject + + btpl := template.Must(template.New("issue/default").Parse("issue/default/body")) + template.Must(btpl.New("issue/new").Parse("issue/new/body")) + template.Must(btpl.New("pull/comment").Parse("pull/comment/body")) + template.Must(btpl.New("issue/close").Parse("issue/close/body")) + + InitMailRender(stpl, btpl) + + expect := func(t *testing.T, msg *Message, expSubject, expBody string) { + subject := msg.GetHeader("Subject") + msgbuf := new(bytes.Buffer) + _, _ = msg.WriteTo(msgbuf) + wholemsg := msgbuf.String() + assert.Equal(t, []string{expSubject}, subject) + assert.Contains(t, wholemsg, expBody) + } + + msg := composeIssueCommentMessage(issue, doer, models.ActionCreateIssue, false, "test body", nil, tos, "TestTemplateSelection") + expect(t, msg, "issue/new/subject", "issue/new/body") + + comment := models.AssertExistsAndLoadBean(t, &models.Comment{ID: 2, Issue: issue}).(*models.Comment) + msg = composeIssueCommentMessage(issue, doer, models.ActionCommentIssue, false, "test body", comment, tos, "TestTemplateSelection") + expect(t, msg, "issue/default/subject", "issue/default/body") + + pull := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 2, Repo: repo, Poster: doer}).(*models.Issue) + comment = models.AssertExistsAndLoadBean(t, &models.Comment{ID: 4, Issue: pull}).(*models.Comment) + msg = composeIssueCommentMessage(pull, doer, models.ActionCommentIssue, false, "test body", comment, tos, "TestTemplateSelection") + expect(t, msg, "pull/comment/subject", "pull/comment/body") + + msg = composeIssueCommentMessage(issue, doer, models.ActionCloseIssue, false, "test body", nil, tos, "TestTemplateSelection") + expect(t, msg, "[user2/repo1] issue1 (#1)", "issue/close/body") +} + +func TestTemplateServices(t *testing.T) { + assert.NoError(t, models.PrepareTestDatabase()) + var mailService = setting.Mailer{ + From: "test@gitea.com", + } + + setting.MailService = &mailService + setting.Domain = "localhost" + + doer := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User) + repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1, Owner: doer}).(*models.Repository) + issue := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 1, Repo: repo, Poster: doer}).(*models.Issue) + comment := models.AssertExistsAndLoadBean(t, &models.Comment{ID: 2, Issue: issue}).(*models.Comment) + assert.NoError(t, issue.LoadRepo()) + + expect := func(t *testing.T, issue *models.Issue, comment *models.Comment, doer *models.User, + actionType models.ActionType, fromMention bool, tplSubject, tplBody, expSubject, expBody string) { + + stpl := texttmpl.Must(texttmpl.New("issue/default").Parse(tplSubject)) + btpl := template.Must(template.New("issue/default").Parse(tplBody)) + InitMailRender(stpl, btpl) + + tos := []string{"test@gitea.com"} + msg := composeIssueCommentMessage(issue, doer, actionType, fromMention, "test body", comment, tos, "TestTemplateServices") + + subject := msg.GetHeader("Subject") + msgbuf := new(bytes.Buffer) + _, _ = msg.WriteTo(msgbuf) + wholemsg := msgbuf.String() + + assert.Equal(t, []string{expSubject}, subject) + assert.Contains(t, wholemsg, "\r\n"+expBody+"\r\n") + } + + expect(t, issue, comment, doer, models.ActionCommentIssue, false, + "{{.SubjectPrefix}}[{{.Repo}}]: @{{.Doer.Name}} commented on #{{.Issue.Index}} - {{.Issue.Title}}", + "//{{.ActionType}},{{.ActionName}},{{if .IsMention}}norender{{end}}//", + "Re: [user2/repo1]: @user2 commented on #1 - issue1", + "//issue,comment,//") + + expect(t, issue, comment, doer, models.ActionCommentIssue, true, + "{{if .IsMention}}must render{{end}}", + "//subject is: {{.Subject}}//", + "must render", + "//subject is: must render//") + + expect(t, issue, comment, doer, models.ActionCommentIssue, true, + "{{.FallbackSubject}}", + "//{{.SubjectPrefix}}//", + "Re: [user2/repo1] issue1 (#1)", + "//Re: //") +} diff --git a/services/release/release.go b/services/release/release.go index a3f027c949..681e8c0d9a 100644 --- a/services/release/release.go +++ b/services/release/release.go @@ -12,10 +12,9 @@ import ( "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/notification" "code.gitea.io/gitea/modules/process" - api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" - "code.gitea.io/gitea/modules/webhook" ) func createTag(gitRepo *git.Repository, rel *models.Release) error { @@ -81,19 +80,7 @@ func CreateRelease(gitRepo *git.Repository, rel *models.Release, attachmentUUIDs } if !rel.IsDraft { - if err := rel.LoadAttributes(); err != nil { - log.Error("LoadAttributes: %v", err) - } else { - mode, _ := models.AccessLevel(rel.Publisher, rel.Repo) - if err := webhook.PrepareWebhooks(rel.Repo, models.HookEventRelease, &api.ReleasePayload{ - Action: api.HookReleasePublished, - Release: rel.APIFormat(), - Repository: rel.Repo.APIFormat(mode), - Sender: rel.Publisher.APIFormat(), - }); err != nil { - log.Error("PrepareWebhooks: %v", err) - } - } + notification.NotifyNewRelease(rel) } return nil @@ -114,20 +101,7 @@ func UpdateRelease(doer *models.User, gitRepo *git.Repository, rel *models.Relea log.Error("AddReleaseAttachments: %v", err) } - if err = rel.LoadAttributes(); err != nil { - return err - } - - // even if attachments added failed, hooks will be still triggered - mode, _ := models.AccessLevel(doer, rel.Repo) - if err1 := webhook.PrepareWebhooks(rel.Repo, models.HookEventRelease, &api.ReleasePayload{ - Action: api.HookReleaseUpdated, - Release: rel.APIFormat(), - Repository: rel.Repo.APIFormat(mode), - Sender: doer.APIFormat(), - }); err1 != nil { - log.Error("PrepareWebhooks: %v", err) - } + notification.NotifyUpdateRelease(doer, rel) return err } @@ -183,15 +157,7 @@ func DeleteReleaseByID(id int64, doer *models.User, delTag bool) error { } } - mode, _ := models.AccessLevel(doer, rel.Repo) - if err := webhook.PrepareWebhooks(rel.Repo, models.HookEventRelease, &api.ReleasePayload{ - Action: api.HookReleaseDeleted, - Release: rel.APIFormat(), - Repository: rel.Repo.APIFormat(mode), - Sender: doer.APIFormat(), - }); err != nil { - log.Error("PrepareWebhooks: %v", err) - } + notification.NotifyDeleteRelease(doer, rel) return nil } diff --git a/templates/mail/issue/assigned.tmpl b/templates/mail/issue/assigned.tmpl index ab06ade1f4..997e2447fc 100644 --- a/templates/mail/issue/assigned.tmpl +++ b/templates/mail/issue/assigned.tmpl @@ -6,11 +6,11 @@ </head> <body> - <p>@{{.Doer.Name}} assigned you to the {{if eq .Issue.IsPull true}}pull request{{else}}issue{{end}} <a href="{{.Link}}">#{{.Issue.Index}}</a> in repository {{.Issue.Repo.FullName}}.</p> + <p>@{{.Doer.Name}} assigned you to the {{if .IsPull}}pull request{{else}}issue{{end}} <a href="{{.Link}}">#{{.Issue.Index}}</a> in repository {{.Repo}}.</p> <p> --- <br> - <a href="{{.Link}}">View it on Gitea</a>. + <a href="{{.Link}}">View it on {{AppName}}</a>. </p> </body> diff --git a/templates/mail/issue/comment.tmpl b/templates/mail/issue/comment.tmpl deleted file mode 100644 index cc86addaf0..0000000000 --- a/templates/mail/issue/comment.tmpl +++ /dev/null @@ -1,16 +0,0 @@ -<!DOCTYPE html> -<html> -<head> - <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> - <title>{{.Subject}}</title> -</head> - -<body> - <p>{{.Body | Str2html}}</p> - <p> - --- - <br> - <a href="{{.Link}}">View it on Gitea</a>. - </p> -</body> -</html> diff --git a/templates/mail/issue/default.tmpl b/templates/mail/issue/default.tmpl new file mode 100644 index 0000000000..ee15d6d8e1 --- /dev/null +++ b/templates/mail/issue/default.tmpl @@ -0,0 +1,31 @@ +<!DOCTYPE html> +<html> +<head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> + <title>{{.Subject}}</title> +</head> + +<body> + {{if .IsMention}}<p>@{{.Doer.Name}} mentioned you:</p>{{end}} + <p> + {{- if eq .Body ""}} + {{if eq .ActionName "new"}} + Created #{{.Issue.Index}}. + {{else if eq .ActionName "close"}} + Closed #{{.Issue.Index}}. + {{else if eq .ActionName "reopen"}} + Reopened #{{.Issue.Index}}. + {{else}} + Empty comment on #{{.Issue.Index}}. + {{end}} + {{else}} + {{.Body | Str2html}} + {{end -}} + </p> + <p> + --- + <br> + <a href="{{.Link}}">View it on {{AppName}}</a>. + </p> +</body> +</html> diff --git a/templates/mail/issue/mention.tmpl b/templates/mail/issue/mention.tmpl deleted file mode 100644 index 032eea053d..0000000000 --- a/templates/mail/issue/mention.tmpl +++ /dev/null @@ -1,17 +0,0 @@ -<!DOCTYPE html> -<html> -<head> - <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> - <title>{{.Subject}}</title> -</head> - -<body> - <p>@{{.Doer.Name}} mentioned you:</p> - <p>{{.Body | Str2html}}</p> - <p> - --- - <br> - <a href="{{.Link}}">View it on Gitea</a>. - </p> -</body> -</html> diff --git a/templates/org/team/new.tmpl b/templates/org/team/new.tmpl index fb79c9b7fb..e50a1777d2 100644 --- a/templates/org/team/new.tmpl +++ b/templates/org/team/new.tmpl @@ -24,6 +24,24 @@ <span class="help">{{.i18n.Tr "org.team_desc_helper"}}</span> </div> {{if not (eq .Team.LowerName "owners")}} + <div class="grouped field"> + <label>{{.i18n.Tr "org.team_access_desc"}}</label> + <br> + <div class="field"> + <div class="ui radio checkbox"> + <input type="radio" name="repo_access" value="specific" {{if not .Team.IncludesAllRepositories}}checked{{end}}> + <label>{{.i18n.Tr "org.teams.specific_repositories"}}</label> + <span class="help">{{.i18n.Tr "org.teams.specific_repositories_helper"}}</span> + </div> + </div> + <div class="field"> + <div class="ui radio checkbox"> + <input type="radio" name="repo_access" value="all" {{if .Team.IncludesAllRepositories}}checked{{end}}> + <label>{{.i18n.Tr "org.teams.all_repositories"}}</label> + <span class="help">{{.i18n.Tr "org.teams.all_repositories_helper"}}</span> + </div> + </div> + </div> <div class="grouped field"> <label>{{.i18n.Tr "org.team_permission_desc"}}</label> <br> diff --git a/templates/org/team/repositories.tmpl b/templates/org/team/repositories.tmpl index eeb86564fd..1b2a411c2b 100644 --- a/templates/org/team/repositories.tmpl +++ b/templates/org/team/repositories.tmpl @@ -7,7 +7,7 @@ {{template "org/team/sidebar" .}} <div class="ui ten wide column"> {{template "org/team/navbar" .}} - {{$canAddRemove := and $.IsOrganizationOwner (not (eq $.Team.LowerName "owners"))}} + {{$canAddRemove := and $.IsOrganizationOwner (not $.Team.IncludesAllRepositories)}} {{if $canAddRemove}} <div class="ui attached segment"> <form class="ui form" id="add-repo-form" action="{{$.OrgLink}}/teams/{{$.Team.LowerName}}/action/repo/add" method="post"> diff --git a/templates/org/team/sidebar.tmpl b/templates/org/team/sidebar.tmpl index 846613e32e..dd189df5f3 100644 --- a/templates/org/team/sidebar.tmpl +++ b/templates/org/team/sidebar.tmpl @@ -22,11 +22,23 @@ {{if eq .Team.LowerName "owners"}} {{.i18n.Tr "org.teams.owners_permission_desc" | Str2html}} {{else if (eq .Team.Authorize 1)}} - {{.i18n.Tr "org.teams.read_permission_desc" | Str2html}} + {{if .Team.IncludesAllRepositories}} + {{.i18n.Tr "org.teams.all_repositories_read_permission_desc" | Str2html}} + {{else}} + {{.i18n.Tr "org.teams.read_permission_desc" | Str2html}} + {{end}} {{else if (eq .Team.Authorize 2)}} - {{.i18n.Tr "org.teams.write_permission_desc" | Str2html}} + {{if .Team.IncludesAllRepositories}} + {{.i18n.Tr "org.teams.all_repositories_write_permission_desc" | Str2html}} + {{else}} + {{.i18n.Tr "org.teams.write_permission_desc" | Str2html}} + {{end}} {{else if (eq .Team.Authorize 3)}} - {{.i18n.Tr "org.teams.admin_permission_desc" | Str2html}} + {{if .Team.IncludesAllRepositories}} + {{.i18n.Tr "org.teams.all_repositories_admin_permission_desc" | Str2html}} + {{else}} + {{.i18n.Tr "org.teams.admin_permission_desc" | Str2html}} + {{end}} {{end}} </div> </div> diff --git a/templates/repo/issue/list.tmpl b/templates/repo/issue/list.tmpl index d68e6dac26..b4c2d88bfd 100644 --- a/templates/repo/issue/list.tmpl +++ b/templates/repo/issue/list.tmpl @@ -258,8 +258,9 @@ </span> {{end}} {{if ne .DeadlineUnix 0}} - <span class="octicon octicon-calendar"></span> - <span{{if .IsOverdue}} class="overdue"{{end}}>{{.DeadlineUnix.FormatShort}}</span> + <span class="due-date poping up" data-content="{{$.i18n.Tr "repo.issues.due_date"}}" data-variation="tiny inverted" data-position="right center"> + <span class="octicon octicon-calendar"></span><span{{if .IsOverdue}} class="overdue"{{end}}>{{.DeadlineUnix.FormatShort}}</span> + </span> {{end}} {{range .Assignees}} <a class="ui right assignee poping up" href="{{.HomeLink}}" data-content="{{.Name}}" data-variation="inverted" data-position="left center"> diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 43c92ea55e..1fcf48d8bc 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -8242,6 +8242,10 @@ "type": "string", "x-go-name": "Description" }, + "includes_all_repositories": { + "type": "boolean", + "x-go-name": "IncludesAllRepositories" + }, "name": { "type": "string", "x-go-name": "Name" @@ -8801,6 +8805,10 @@ "type": "string", "x-go-name": "Description" }, + "includes_all_repositories": { + "type": "boolean", + "x-go-name": "IncludesAllRepositories" + }, "name": { "type": "string", "x-go-name": "Name" @@ -10457,6 +10465,10 @@ "format": "int64", "x-go-name": "ID" }, + "includes_all_repositories": { + "type": "boolean", + "x-go-name": "IncludesAllRepositories" + }, "name": { "type": "string", "x-go-name": "Name" diff --git a/templates/user/dashboard/issues.tmpl b/templates/user/dashboard/issues.tmpl index ac5f10a599..5418c6de47 100644 --- a/templates/user/dashboard/issues.tmpl +++ b/templates/user/dashboard/issues.tmpl @@ -126,6 +126,11 @@ <span class="octicon octicon-checklist"></span> {{$tasksDone}} / {{$tasks}} <span class="progress-bar"><span class="progress" style="width:calc(100% * {{$tasksDone}} / {{$tasks}});"></span></span> </span> {{end}} + {{if ne .DeadlineUnix 0}} + <span class="due-date poping up" data-content="{{$.i18n.Tr "repo.issues.due_date"}}" data-variation="tiny inverted" data-position="right center"> + <span class="octicon octicon-calendar"></span><span{{if .IsOverdue}} class="overdue"{{end}}>{{.DeadlineUnix.FormatShort}}</span> + </span> + {{end}} </p> </li> {{end}}