feat: Majority Judgment Polls (2023-11-23)

Made for Gitea 1.20.5

Features
--------

- Adds an optional "Poll" tab menu to repositories.
- Polls use Majority Judgment.
- Badge Polls can be held per repository, using issues as candidates.
- Badge Polls can target a subset of Issues and Merge Requests.
- Everything works without javascript (except the "delete" action).

Bouerk
------

- Code needs more polishing.
- Needs more thinking to allow Inline Polls in Issues.
- Ideally this whole thing should probably be a plugin and not a fork.
- The merit profile is shown to all logged in, whether they judged or not.
mj-v1.20.5
Dominique Merle 4 years ago
parent 4126aad4aa
commit 81c1ef0b16

@ -0,0 +1,29 @@
#!/bin/env sh
# Helper tool to make AsciiArt titles as comments in the code.
# This should probably be a recipe in the Makefile.
#
# Install
# -------
#
# sudo apt install figlet xclip git
# cd /tmp
# git clone https://github.com/xero/figlet-fonts
# sudo mv -n figlet-fonts/* /usr/share/figlet/
# cd -
if [ "$#" -eq 0 ] ; then
echo "Helper tool to generate AsciiArt titles as comments in the code."
echo "It will automatically copy the result into your clipboard."
echo ""
echo "Usage: ./title.sh \"My Awesome Title\""
exit 1
fi
figlet -f Graffiti -w 300 "$@" \
| sed -e 's|^|// |' \
| sed -e 's/[ ]*$//' \
| tee /dev/tty \
| xclip -in -selection clipboard
#echo "\nThe above AsciiArt was also pasted into the clipboard."

@ -921,14 +921,14 @@ LEVEL = Info
;GO_GET_CLONE_URL_PROTOCOL = https
;;
;; Close issues as long as a commit on any branch marks it as fixed
;; Comma separated list of globally disabled repo units. Allowed values: repo.issues, repo.ext_issues, repo.pulls, repo.wiki, repo.ext_wiki, repo.projects, repo.packages, repo.actions.
;; Comma separated list of globally disabled repo units. Allowed values: repo.issues, repo.ext_issues, repo.pulls, repo.wiki, repo.ext_wiki, repo.projects, repo.packages, repo.actions, repo.polls.
;DISABLED_REPO_UNITS =
;;
;; Comma separated list of default new repo units. Allowed values: repo.code, repo.releases, repo.issues, repo.pulls, repo.wiki, repo.projects, repo.packages, repo.actions.
;; Comma separated list of default new repo units. Allowed values: repo.code, repo.releases, repo.issues, repo.pulls, repo.wiki, repo.projects, repo.packages, repo.actions, repo.polls.
;; Note: Code and Releases can currently not be deactivated. If you specify default repo units you should still list them for future compatibility.
;; External wiki and issue tracker can't be enabled by default as it requires additional settings.
;; Disabled repo units will not be added to new repositories regardless if it is in the default list.
;DEFAULT_REPO_UNITS = repo.code,repo.releases,repo.issues,repo.pulls,repo.wiki,repo.projects,repo.packages
;DEFAULT_REPO_UNITS = repo.code,repo.releases,repo.issues,repo.pulls,repo.wiki,repo.projects,repo.packages,repo.polls
;;
;; Comma separated list of default forked repo units.
;; The set of allowed values and rules are the same as DEFAULT_REPO_UNITS.

@ -77,6 +77,7 @@ require (
github.com/meilisearch/meilisearch-go v0.24.0
github.com/mholt/archiver/v3 v3.5.1
github.com/microcosm-cc/bluemonday v1.0.25
github.com/mieuxvoter/majority-judgment-library-go v0.3.3
github.com/minio/minio-go/v7 v7.0.52
github.com/minio/sha256-simd v1.0.0
github.com/msteinert/pam v1.1.0
@ -221,6 +222,7 @@ require (
github.com/kr/pretty v0.3.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/libdns/libdns v0.2.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/markbates/going v1.0.0 // indirect

@ -142,6 +142,7 @@ github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:W
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@ -376,6 +377,8 @@ github.com/go-ldap/ldap/v3 v3.4.4/go.mod h1:fe1MsuN5eJJ1FeLT/LEBVdWfNWKh459R7aXg
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/analysis v0.21.2/go.mod h1:HZwRk4RRisyG8vx2Oe6aqeSQcoxRp47Xkp3+K6q+LdY=
github.com/go-openapi/analysis v0.21.4 h1:ZDFLvSNxpDaomuCueM0BlSXxpANBlFYiBvr+GXrvIHc=
github.com/go-openapi/analysis v0.21.4/go.mod h1:4zQ35W4neeZTqh3ol0rv/O8JBbka9QyAgQRPp9y3pfo=
@ -689,6 +692,7 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
@ -697,6 +701,7 @@ github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVY
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4=
github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
@ -753,6 +758,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/libdns/libdns v0.2.1 h1:Wu59T7wSHRgtA0cfxC+n1c/e+O3upJGWytknkmFEDis=
github.com/libdns/libdns v0.2.1/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lunny/log v0.0.0-20160921050905-7887c61bf0de/go.mod h1:3q8WtuPQsoRbatJuy3nvq/hRSvuBJrHHr+ybPPiNvHQ=
github.com/lunny/nodb v0.0.0-20160621015157-fc1ef06ad4af/go.mod h1:Cqz6pqow14VObJ7peltM+2n3PWOz7yTrfUuGbVFkzN0=
github.com/lunny/vfsgen v0.0.0-20220105142115-2c99e1ffdfa0 h1:F/3FfGmKdiKFa8kL3YrpZ7pe9H4l4AzA1pbaOUnRvPI=
@ -803,6 +810,10 @@ github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJ
github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE=
github.com/miekg/dns v1.1.54 h1:5jon9mWcb0sFJGpnI99tOMhCPyJ+RPVz5b63MQG0VWI=
github.com/miekg/dns v1.1.54/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
github.com/mieuxvoter/majority-judgment-library-go v0.3.2 h1:vV3HaeURioCTXRj1EQQHkMxOhx7H003HZQP6FLemIww=
github.com/mieuxvoter/majority-judgment-library-go v0.3.2/go.mod h1:3WukXYakDhUFmGHmDP0escphOjNZE2lYaEHcqZ7fc28=
github.com/mieuxvoter/majority-judgment-library-go v0.3.3 h1:BtzXQaCa65B60+BQghss5RVdqkFSFYv/RP/wtLTRn2o=
github.com/mieuxvoter/majority-judgment-library-go v0.3.3/go.mod h1:3WukXYakDhUFmGHmDP0escphOjNZE2lYaEHcqZ7fc28=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.52 h1:8XhG36F6oKQUDDSuz6dY3rioMzovKjW40W6ANuN0Dps=
@ -838,6 +849,7 @@ github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOl
github.com/msteinert/pam v1.1.0 h1:VhLun/0n0kQYxiRBJJvVpC2jR6d21SWJFjpvUVj20Kc=
github.com/msteinert/pam v1.1.0/go.mod h1:M4FPeAW8g2ITO68W8gACDz13NDJyOQM9IQsQhrR6TOI=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=

@ -0,0 +1,268 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package poll
import (
"code.gitea.io/gitea/models/db"
issue_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/timeutil"
"fmt"
mj "github.com/mieuxvoter/majority-judgment-library-go/judgment"
"regexp"
"strconv"
)
// A Poll on Subject with the issues of a repository as candidates.
type Poll struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX"`
Repo *repo_model.Repository `xorm:"-"`
AuthorID int64 `xorm:"INDEX"`
Author *user_model.User `xorm:"-"`
// Index int64 `xorm:"UNIQUE(repo_index)"` // Index in one repository.
// The Subject of the poll should be short.
// When the poll uses issues as candidates, the subject should be an issue's trait.
// eg: Quality, Importance, Urgency, Wholeness, Relevance, Enthusiasm…
Subject string `xorm:"name"`
// Description may be used to describe at length the constitutive details of the Poll.
// Eg: Rationale, Deliberation Consequences, Schedule, Modus Operandi…
// It can be written in the usual gitea-flavored markdown.
Description string `xorm:"TEXT"`
RenderedDescription string `xorm:"-"`
//Ref string // Do we need this? Are we even using it? What is it?
Gradation string `xorm:"-"`
// Examples of a Candidates Allowed List:
// 15
// 1, 12, #13
// Ideas:
// 3-13, 15-99
// tag=bug, tag=bugfix, 21878
// or perhaps
// [bug], [bugfix], 21878
// but ideally an issue _search bar_ field
CandidatesAllowedList string `xorm:"candidates_allowed_list"`
AreCandidatesIssues bool // unused
DeadlineUnix timeutil.TimeStamp `xorm:"INDEX"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
ClosedUnix timeutil.TimeStamp `xorm:"INDEX"`
}
func init() {
db.RegisterModel(new(Poll))
}
// PollList is a list of polls offering additional functionality (perhaps)
type PollList []*Poll
// Link to the Poll view page. This panics half the time.
func (poll *Poll) Link() string {
return poll.Repo.Link() + fmt.Sprintf("/polls/%d", poll.ID)
}
func (poll *Poll) GetGradationList() []string {
list := make([]string, 0, 6)
// TODO Placeholder until user customization somehow (poll.Gradation?)
// - 🤮😒😐🙂😀🤩
// - 😫😒😐😌😀😍 (more support, apparently)
// - …
list = append(list, "😫")
list = append(list, "😒")
list = append(list, "😐")
list = append(list, "😌")
list = append(list, "😀")
list = append(list, "😍")
return list
}
func (poll *Poll) GetGradeColorWord(grade uint8) (_ string) {
// TODO Another placeholder, bypassing the dragon for now
switch grade {
case 0:
return "red"
case 1:
return "red"
case 2:
return "orange"
case 3:
return "yellow"
case 4:
return "olive"
case 5:
return "green"
default:
return "green"
}
}
func (poll *Poll) GetGradeColorCode(grade uint8) (_ string) {
// TODO Another placeholder, bypassing the dragon for now
switch grade {
case 0:
return "#E0361C"
case 1:
return "#EE6E00"
case 2:
return "#FDB200"
case 3:
return "#C6D700"
case 4:
return "#7EC239"
case 5:
return "#02AB58"
case 6:
return "#007E3D"
default:
return "#007E3D"
}
}
// GetCandidatesIDs collects the IDs of Candidates having received at least one judgment.
func (poll *Poll) GetCandidatesIDs() []int64 {
ids := make([]int64, 0, 8)
_ = db.GetEngine(db.DefaultContext).
Table("poll_judgment").
Select("DISTINCT `poll_judgment`.`candidate_id`").
Where("`poll_judgment`.`poll_id` = ?", poll.ID).
OrderBy("`poll_judgment`.`candidate_id` ASC").
Find(&ids)
return ids
}
// AllowsIssueAsCandidate checks if this Poll may use the provided Issue as Candidate
func (poll *Poll) AllowsIssueAsCandidate(issue *issue_model.Issue) bool {
if poll.CandidatesAllowedList == "" {
return true
}
// Idea: Instead of handling our parsing here, perhaps use the search bar utils ?
csvSeperator := regexp.MustCompile(`\s*,\s*`)
issueIdRegex := regexp.MustCompile(`#?(?P<id>[0-9]+)`)
issueIdIndex := issueIdRegex.SubexpIndex("id")
candidatesFilters := csvSeperator.Split(poll.CandidatesAllowedList, -1)
for _, candidateFilter := range candidatesFilters {
issueIdMatches := issueIdRegex.FindStringSubmatch(candidateFilter)
if nil != issueIdMatches {
acceptedIssueID, err := strconv.Atoi(issueIdMatches[issueIdIndex])
if nil == err {
if issue.Index == int64(acceptedIssueID) {
// fmt.Println("YEP")
return true
}
}
}
// … perhaps match tags as well, and other filters ? → search bar !?!
// /!. CURRENT IMPLEMENTATION ABOVE WILL CHOKE ON COMMAS IN TAG NAMES
}
return false
}
func (poll *Poll) GetIdOfCandidateAtIndex(candidateIndex int) int64 {
for index, someCandidateID := range poll.GetCandidatesIDs() {
if index == candidateIndex {
return someCandidateID
}
}
return -1
}
func (poll *Poll) GetIndexOfCandidate(candidateID int64) int {
for index, someCandidateID := range poll.GetCandidatesIDs() {
if someCandidateID == candidateID {
return index
}
}
return -1
}
func (poll *Poll) GetNameOfCandidate(candidateID int64) string {
issue, err := issue_model.GetIssueByIndex(poll.RepoID, candidateID)
if err != nil {
return "Issue not found"
}
return issue.Title
}
func (poll *Poll) GetJudgmentOnCandidate(judge *user_model.User, candidateID int64) *PollJudgment {
judgment, err := getJudgmentOfJudgeOnPollCandidate(db.DefaultContext, judge.ID, poll.ID, candidateID)
if nil != err {
return nil
}
return judgment
}
func (poll *Poll) GetResult() (results *PollResult) {
proposalsTallies := make([]*mj.ProposalTally, 0, 8)
for _, candidateID := range poll.GetCandidatesIDs() {
tally := make([]uint64, 0, 8)
for k, _ := range poll.GetGradationList() {
gradeTally, _ := poll.CountGrades(candidateID, uint8(k))
tally = append(tally, gradeTally)
}
proposalTally := &mj.ProposalTally{
Tally: tally,
}
proposalsTallies = append(proposalsTallies, proposalTally)
}
pollTally := &mj.PollTally{
Proposals: proposalsTallies,
}
pollTally.GuessAmountOfJudges()
_ = pollTally.BalanceWithStaticDefault(0)
ranker := &mj.MajorityJudgment{}
pollResultBase, err := ranker.Deliberate(pollTally)
if err != nil {
fmt.Println(err)
return nil
}
pollResult := &PollResult{
PollResult: *pollResultBase,
Poll: poll,
}
return pollResult
}
func (poll *Poll) CountGrades(candidateID int64, grade uint8) (_ uint64, err error) {
rows := make([]int64, 0, 2)
if err := db.GetEngine(db.DefaultContext).
Table("poll_judgment").
Select("COUNT(*) as amount").
Where("`poll_judgment`.`poll_id` = ?", poll.ID).
And("`poll_judgment`.`candidate_id` = ?", candidateID).
And("`poll_judgment`.`grade` = ?", grade).
// Use Get() perhaps?
Find(&rows); err != nil {
return 0, err
}
if 1 != len(rows) {
return 0, fmt.Errorf("wrong amount of COUNT()")
}
amount := uint64(rows[0])
return amount, nil
}

@ -0,0 +1,78 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package poll
import (
"math"
"code.gitea.io/gitea/modules/timeutil"
)
type PollCandidateMeritProfile struct {
Poll *Poll
CandidateID int64 // Issue Index (or internal candidate index, later on)
Rank uint64 // Two Candidates may share the same Rank. (perfect equality)
Grades []uint64 // Amount of Judgments per Grade
JudgmentsAmount uint64
CreatedUnix timeutil.TimeStamp
}
// _________
// / _____/__ ______
// \_____ \\ \/ / ___\
// / \\ / /_/ >
// /_______ / \_/\___ /
// \/ /_____/
type CartesianVector2 struct {
X float64
Y float64
}
type TwoCirclePoints struct {
Start *CartesianVector2
End *CartesianVector2
}
func (merit *PollCandidateMeritProfile) GetGradeAngle(gradeID int, gapHalfAngle float64) (_ float64) {
TAU := 6.283185307179586 // Sigh ; how is this not in Golang yet?
totalAngle := TAU - gapHalfAngle*2.0
return totalAngle * float64(merit.Grades[gradeID]) / float64(merit.JudgmentsAmount)
}
func (merit *PollCandidateMeritProfile) GetCirclePoints(gradeID int, radius, halfGap float64) (_ *TwoCirclePoints) {
TAU := 6.283185307179586 // Sigh ; how is this not in Golang yet?
gapHalfAngle := halfGap
// gapHalfAngle = 0.39192267544687825 * 0.96 // asin(GOLDEN_RATIO-1)
// gapHalfAngle = TAU / 4.0 // Hemicycle
// gapHalfAngle = 0.0 // Camembert (du fromage!)
totalAngle := TAU - gapHalfAngle*2.0
lowerGradeJudgmentsAmount := uint64(0)
for i := 0; i < gradeID; i++ {
lowerGradeJudgmentsAmount += merit.Grades[i]
}
totalJudgments := float64(merit.JudgmentsAmount)
startingAngle := gapHalfAngle + totalAngle*float64(lowerGradeJudgmentsAmount)/totalJudgments
angle := totalAngle * float64(merit.Grades[gradeID]) / totalJudgments
endingAngle := startingAngle + angle
return &TwoCirclePoints{
Start: &CartesianVector2{
X: radius * math.Cos(startingAngle),
Y: radius * math.Sin(startingAngle),
},
End: &CartesianVector2{
X: radius * math.Cos(endingAngle),
Y: radius * math.Sin(endingAngle),
},
}
}
func (merit *PollCandidateMeritProfile) GetColorWord(gradeID int) (_ string) {
return merit.Poll.GetGradeColorWord(uint8(gradeID))
}
func (merit *PollCandidateMeritProfile) GetColorCode(gradeID int) (_ string) {
return merit.Poll.GetGradeColorCode(uint8(gradeID))
}

@ -0,0 +1,196 @@
package poll
import (
"code.gitea.io/gitea/models/db"
issue_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
"context"
"strings"
)
// _________ __
// \_ ___ \_______ ____ _____ _/ |_ ____
// / \ \/\_ __ \_/ __ \\__ \\ __\/ __ \
// \ \____| | \/\ ___/ / __ \| | \ ___/
// \______ /|__| \___ >____ /__| \___ >
// \/ \/ \/ \/
type CreatePollOptions struct {
// Type PollType // TODO for inline polls with their own candidates?
Author *user_model.User
Repo *repo_model.Repository
Subject string
Description string
CandidatesAllowedList string
// Grades string
}
// CreatePoll creates a new Poll with the provided options.
func CreatePoll(options *CreatePollOptions) (poll *Poll, err error) {
ctx, committer, err := db.TxContext(db.DefaultContext)
if err != nil {
return nil, err
}
defer committer.Close()
poll, err = createPoll(ctx, options)
if err != nil {
return nil, err
}
if err = committer.Commit(); err != nil {
return nil, err
}
return poll, nil
}
func createPoll(ctx context.Context, opts *CreatePollOptions) (_ *Poll, err error) {
poll := &Poll{
AuthorID: opts.Author.ID,
Author: opts.Author,
RepoID: opts.Repo.ID,
Repo: opts.Repo,
Subject: opts.Subject,
Description: opts.Description,
CandidatesAllowedList: opts.CandidatesAllowedList,
AreCandidatesIssues: true,
}
if _, err = db.GetEngine(ctx).Insert(poll); err != nil {
return nil, err
}
// Why do we load the owner here ?
if err = opts.Repo.LoadOwner(ctx); err != nil {
return nil, err
}
//if err = updatePollInfos(e, opts, poll); err != nil {
// return nil, err
//}
return poll, nil
}
// __________ .___
// \______ \ ____ _____ __| _/
// | _// __ \\__ \ / __ |
// | | \ ___/ / __ \_/ /_/ |
// |____|_ /\___ >____ /\____ |
// \/ \/ \/ \/
// GetPolls returns the (paginated) list of polls of a given repository.
func GetPolls(repoID int64, page int) (PollList, error) {
polls := make([]*Poll, 0, setting.UI.IssuePagingNum)
query := db.GetEngine(db.DefaultContext).Where("repo_id = ?", repoID)
if page > 0 {
query = query.Limit(setting.UI.PollsPagingNum, (page-1)*setting.UI.PollsPagingNum)
} else {
query = query.Limit(setting.UI.PollsPagingNum)
}
return polls, query.Find(&polls)
}
func getPollByRepoID(ctx context.Context, repoID, id int64) (*Poll, error) {
poll := new(Poll)
has, err := db.GetEngine(ctx).ID(id).Where("repo_id = ?", repoID).Get(poll)
if err != nil {
return nil, err
} else if !has {
return nil, ErrPollNotFound{PollID: id, RepoID: repoID}
}
return poll, nil
}
// GetPollByRepoID returns the poll in a repository.
func GetPollByRepoID(repoID, id int64) (*Poll, error) {
return getPollByRepoID(db.DefaultContext, repoID, id)
}
// GetPollsUsingIssueAsCandidate returns the badge polls using the provided issue as candidate.
func GetPollsUsingIssueAsCandidate(issue *issue_model.Issue) (PollList, error) {
polls := make([]*Poll, 0, 8)
query := db.GetEngine(db.DefaultContext).Where("repo_id = ?", issue.RepoID)
err := query.Find(&polls)
if err != nil {
return polls, err
}
filteredPolls := make([]*Poll, 0, len(polls))
for _, poll := range polls {
if poll.AllowsIssueAsCandidate(issue) {
filteredPolls = append(filteredPolls, poll)
}
}
return filteredPolls, nil
}
// CountPollsInRepo returns the amount of all the Polls in the provided repository.
func CountPollsInRepo(repoID int64) (int64, error) {
query := db.GetEngine(db.DefaultContext).Where("repo_id = ?", repoID)
return query.Count(new(Poll))
}
// ____ ___ .___ __
// | | \______ __| _/____ _/ |_ ____
// | | /\____ \ / __ |\__ \\ __\/ __ \
// | | / | |_> > /_/ | / __ \| | \ ___/
// |______/ | __/\____ |(____ /__| \___ >
// |__| \/ \/ \/
func updatePoll(ctx context.Context, poll *Poll) error {
poll.Subject = strings.TrimSpace(poll.Subject)
_, err := db.GetEngine(ctx).ID(poll.ID).AllCols().Update(poll)
return err
}
// UpdatePoll updates the information of the given poll.
func UpdatePoll(m *Poll) error {
ctx, committer, err := db.TxContext(db.DefaultContext)
if err != nil {
return err
}
defer committer.Close()
if err = updatePoll(ctx, m); err != nil {
return err
}
return committer.Commit()
}
// ________ .__ __
// \______ \ ____ | | _____/ |_ ____
// | | \_/ __ \| | _/ __ \ __\/ __ \
// | ` \ ___/| |_\ ___/| | \ ___/
// /_______ /\___ >____/\___ >__| \___ >
// \/ \/ \/ \/
// DeletePollByRepoID deletes a poll from a repository.
func DeletePollByRepoID(repoID, id int64) error {
m, err := GetPollByRepoID(repoID, id)
if err != nil {
if IsErrPollNotFound(err) {
return nil // not very confident in this ; yelling would be best perhaps?
}
return err
}
ctx, committer, err := db.TxContext(db.DefaultContext)
if err != nil {
return err
}
defer committer.Close()
if _, err = db.GetEngine(ctx).ID(m.ID).Delete(new(Poll)); err != nil {
return err
}
return committer.Commit()
}

@ -0,0 +1,42 @@
package poll
import (
user_model "code.gitea.io/gitea/models/user"
"fmt"
)
// ErrPollNotFound represents an error when a poll was not found.
type ErrPollNotFound struct {
PollID int64
RepoID int64
}
// IsErrPollNotFound checks if an error is a ErrPollNotFound.
func IsErrPollNotFound(err error) bool {
_, ok := err.(ErrPollNotFound)
return ok
}
// Error serializes the ErrPollNotFound error.
func (err ErrPollNotFound) Error() string {
return fmt.Sprintf("poll not found [id: %d, repo_id: %d]", err.PollID, err.RepoID)
}
// ErrJudgmentNotFound is raised when a judgment was not found.
// Perhaps this should only hold primitives, and not pointers ?
type ErrJudgmentNotFound struct {
Judge *user_model.User
Poll *Poll
CandidateID int64
}
// IsErrJudgmentNotFound checks if an error is a ErrJudgmentNotFound.
func IsErrJudgmentNotFound(err error) bool {
_, ok := err.(ErrJudgmentNotFound)
return ok
}
// Error serializes the ErrJudgmentNotFound error.
func (err ErrJudgmentNotFound) Error() string {
return fmt.Sprintf("judgment not found.")
}

@ -0,0 +1,189 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package poll
import (
"context"
"fmt"
"code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/timeutil"
)
// PollJudgment represents a Judgment on a Poll.
type PollJudgment struct {
ID int64 `xorm:"pk autoincr"`
PollID int64 `xorm:"INDEX UNIQUE(poll_judge_candidate)"`
Poll *Poll `xorm:"-"`
JudgeID int64 `xorm:"INDEX UNIQUE(poll_judge_candidate)"`
Judge *user_model.User `xorm:"-"`
// Either an Issue ID or an index in the list of Candidates (for inline polls)
CandidateID int64 `xorm:"UNIQUE(poll_judge_candidate)"`
// There may be other graduations
// 0 = to reject
// 1 = poor
// 2 = passable
// 3 = good
// 4 = very good
// 5 = excellent
// Make sure 0 always means *something* in your graduation
// Various graduations are provided <???>.
Grade uint8
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
}
func init() {
db.RegisterModel(new(PollJudgment))
}
type CreateJudgmentOptions struct {
Poll *Poll
Judge *user_model.User
Grade uint8
CandidateID int64
}
type UpdateJudgmentOptions struct {
Poll *Poll
Judge *user_model.User
Grade uint8
CandidateID int64
}
type DeleteJudgmentOptions struct {
Poll *Poll
Judge *user_model.User
CandidateID int64
}
func CreateJudgment(opts *CreateJudgmentOptions) (judgment *PollJudgment, err error) {
ctx, committer, err := db.TxContext(db.DefaultContext)
if err != nil {
return nil, err
}
defer committer.Close()
judgment, err = createJudgment(ctx, opts)
if err != nil {
return nil, err
}
if err = committer.Commit(); err != nil {
return nil, err
}
return judgment, nil
}
func createJudgment(ctx context.Context, opts *CreateJudgmentOptions) (*PollJudgment, error) {
judgment := &PollJudgment{
PollID: opts.Poll.ID,
Poll: opts.Poll,
JudgeID: opts.Judge.ID,
Judge: opts.Judge,
CandidateID: opts.CandidateID,
Grade: opts.Grade,
}
if _, err := db.GetEngine(ctx).Insert(judgment); err != nil {
return nil, err
}
//if err = updatePollInfos(ctx, opts, poll); err != nil {
// return nil, err
//}
return judgment, nil
}
func getJudgmentByID(ctx context.Context, id int64) (*PollJudgment, error) {
judgment := new(PollJudgment)
has, err := db.GetEngine(ctx).ID(id).Get(judgment)
if err != nil {
return nil, err
} else if !has {
return nil, ErrJudgmentNotFound{}
}
return judgment, nil
}
func getJudgmentOfJudgeOnPollCandidate(ctx context.Context, judgeID, pollID, candidateID int64) (judgment *PollJudgment, err error) {
// We could probably use only one SQL query instead of two here.
judgmentsIds := make([]int64, 0, 1)
if err = db.GetEngine(ctx).Table("poll_judgment").
Cols("id").
Where("`poll_judgment`.`judge_id` = ?", judgeID).
And("`poll_judgment`.`poll_id` = ?", pollID).
And("`poll_judgment`.`candidate_id` = ?", candidateID).
Limit(1). // perhaps .Get() is what we need here?
Find(&judgmentsIds); err != nil {
return nil, fmt.Errorf("find judgment: %v", err)
}
if 0 == len(judgmentsIds) {
return nil, ErrJudgmentNotFound{}
}
judgment, err = getJudgmentByID(ctx, judgmentsIds[0])
if err != nil {
return nil, err
}
return judgment, nil
}
func UpdateJudgment(opts *UpdateJudgmentOptions) (judgment *PollJudgment, err error) {
ctx, committer, err := db.TxContext(db.DefaultContext)
if err != nil {
return nil, err
}
defer committer.Close()
judgment, err = getJudgmentOfJudgeOnPollCandidate(ctx, opts.Judge.ID, opts.Poll.ID, opts.CandidateID)
if nil != err {
return nil, err
}
judgment.Grade = opts.Grade
_, err = db.GetEngine(ctx).ID(judgment.ID).
Cols("grade", "updated_unix").
Update(judgment)
if err != nil {
return nil, err
}
if err = committer.Commit(); err != nil {
return nil, err
}
return judgment, nil
}
func DeleteJudgment(opts *DeleteJudgmentOptions) error {
ctx, committer, err := db.TxContext(db.DefaultContext)
if err != nil {
return err
}
defer committer.Close()
judgment, err := getJudgmentOfJudgeOnPollCandidate(ctx, opts.Judge.ID, opts.Poll.ID, opts.CandidateID)
if nil != err {
return err
}
if _, err = db.GetEngine(ctx).Delete(judgment); nil != err {
return err
}
if err = committer.Commit(); nil != err {
return err
}
return nil
}

@ -0,0 +1,78 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package poll_test
import (
"testing"
poll_model "code.gitea.io/gitea/models/poll"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"github.com/stretchr/testify/assert"
)
func TestPollJudgment_Create(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
// issue := AssertExistsAndLoadBean(t, &Issue{ID: 2}).(*Issue)
// assert.Equal(t, repo, issue.Repo)
poll, errp := poll_model.CreatePoll(&poll_model.CreatePollOptions{
Repo: repo,
Author: user,
Subject: "Quality",
})
assert.NoError(t, errp)
assert.NotNil(t, poll)
judgment, err := poll_model.CreateJudgment(&poll_model.CreateJudgmentOptions{
Judge: user,
Poll: poll,
Grade: 3,
CandidateID: 1,
})
assert.NoError(t, err)
assert.NotNil(t, judgment)
// Emit another Judgment, on another Candidate
judgment, err = poll_model.CreateJudgment(&poll_model.CreateJudgmentOptions{
Judge: user,
Poll: poll,
Grade: 1,
CandidateID: 2,
})
assert.NoError(t, err)
assert.NotNil(t, judgment)
// Cannot create another judgment on the same poll and candidate
judgment, err = poll_model.CreateJudgment(&poll_model.CreateJudgmentOptions{
Judge: user,
Poll: poll,
Grade: 0,
CandidateID: 2,
})
assert.Error(t, err)
assert.Nil(t, judgment)
// … you have to update it
judgment, err = poll_model.UpdateJudgment(&poll_model.UpdateJudgmentOptions{
Judge: user,
Poll: poll,
Grade: 0,
CandidateID: 2,
})
assert.NoError(t, err)
assert.NotNil(t, judgment)
}

@ -0,0 +1,44 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package poll
import (
"code.gitea.io/gitea/modules/timeutil"
mj "github.com/mieuxvoter/majority-judgment-library-go/judgment"
)
// __________ .__ __
// \______ \ ____ ________ __| |_/ |_
// | _// __ \ / ___/ | \ |\ __\
// | | \ ___/ \___ \| | / |_| |
// |____|_ /\___ >____ >____/|____/__|
// \/ \/ \/
type PollResult struct {
mj.PollResult
Poll *Poll
}
func (result *PollResult) GetResultOfCandidate(candidateID int64) *mj.ProposalResult {
index := result.Poll.GetIndexOfCandidate(candidateID)
if index >= 0 && index < len(result.Proposals) {
return result.Proposals[index]
}
return nil
}
func (result *PollResult) GetMaritProfileOfCandidate(candidateID int64) *PollCandidateMeritProfile {
candidateResult := result.GetResultOfCandidate(candidateID)
meritProfile := &PollCandidateMeritProfile{
Poll: result.Poll,
CandidateID: candidateID,
Rank: uint64(candidateResult.Rank),
Grades: candidateResult.Tally.Tally,
JudgmentsAmount: candidateResult.Tally.CountJudgments(),
CreatedUnix: timeutil.TimeStampNow(),
}
return meritProfile
}

@ -0,0 +1,32 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package poll_test
import (
"testing"
poll_model "code.gitea.io/gitea/models/poll"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"github.com/stretchr/testify/assert"
)
func TestPoll_Create(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
opts := poll_model.CreatePollOptions{
Repo: repo,
Author: user,
Subject: "Quality",
}
poll, err := poll_model.CreatePoll(&opts)
assert.NoError(t, err)
assert.NotNil(t, poll)
}

@ -29,6 +29,7 @@ const (
TypeProjects // 8 Kanban board
TypePackages // 9 Packages
TypeActions // 10 Actions
TypePolls // 11 Polls
)
// Value returns integer value for unit type
@ -58,6 +59,8 @@ func (u Type) String() string {
return "TypePackages"
case TypeActions:
return "TypeActions"
case TypePolls:
return "TypePolls"
}
return fmt.Sprintf("Unknown Type %d", u)
}
@ -79,6 +82,7 @@ var (
TypeProjects,
TypePackages,
TypeActions,
TypePolls,
}
// DefaultRepoUnits contains the default unit types
@ -90,6 +94,7 @@ var (
TypeWiki,
TypeProjects,
TypePackages,
TypePolls,
}
// ForkRepoUnits contains the default unit types for forks
@ -301,6 +306,15 @@ var (
perm.AccessModeOwner,
}
UnitPolls = Unit{
TypePolls,
"repo.polls",
"/polls",
"repo.polls.desc",
8,
perm.AccessModeOwner,
}
// Units contains all the units
Units = map[Type]Unit{
TypeCode: UnitCode,
@ -313,6 +327,7 @@ var (
TypeProjects: UnitProjects,
TypePackages: UnitPackages,
TypeActions: UnitActions,
TypePolls: UnitPolls,
}
)
@ -350,7 +365,7 @@ func AllUnitKeyNames() []string {
return res
}
// MinUnitAccessMode returns the minial permission of the permission map
// MinUnitAccessMode returns the minimal permission of the permission map
func MinUnitAccessMode(unitsMap map[Type]perm.AccessMode) perm.AccessMode {
res := perm.AccessModeNone
for t, mode := range unitsMap {
@ -359,7 +374,7 @@ func MinUnitAccessMode(unitsMap map[Type]perm.AccessMode) perm.AccessMode {
continue
}
// get the minial permission great than AccessModeNone except all are AccessModeNone
// get the minimal permission greater than AccessModeNone except all are AccessModeNone
if mode > perm.AccessModeNone && (res == perm.AccessModeNone || mode < res) {
res = mode
}

@ -559,6 +559,7 @@ func RepoAssignment(ctx *Context) (cancel context.CancelFunc) {
ctx.Data["CanWriteCode"] = ctx.Repo.CanWrite(unit_model.TypeCode)
ctx.Data["CanWriteIssues"] = ctx.Repo.CanWrite(unit_model.TypeIssues)
ctx.Data["CanWritePulls"] = ctx.Repo.CanWrite(unit_model.TypePullRequests)
ctx.Data["CanWritePolls"] = ctx.Repo.CanWrite(unit_model.TypePolls)
canSignedUserFork, err := repo_module.CanUserForkRepo(ctx.Doer, ctx.Repo.Repository)
if err != nil {
@ -1040,5 +1041,6 @@ func UnitTypes() func(ctx *Context) {
ctx.Data["UnitTypeProjects"] = unit_model.TypeProjects
ctx.Data["UnitTypePackages"] = unit_model.TypePackages
ctx.Data["UnitTypeActions"] = unit_model.TypeActions
ctx.Data["UnitTypePolls"] = unit_model.TypePolls
}
}

@ -7,9 +7,10 @@ package migration
import (
"io"
"os"
"path"
)
func openSchema(filename string) (io.ReadCloser, error) {
return Assets.Open(path.Base(filename))
return os.Open(path.Base(filename))
}

@ -19,6 +19,7 @@ var UI = struct {
FeedMaxCommitNum int
FeedPagingNum int
PackagesPagingNum int
PollsPagingNum int
GraphMaxCommitNum int
CodeCommentLines int
ReactionMaxUserNum int
@ -72,6 +73,7 @@ var UI = struct {
FeedMaxCommitNum: 5,
FeedPagingNum: 20,
PackagesPagingNum: 20,
PollsPagingNum: 4,
GraphMaxCommitNum: 100,
CodeCommentLines: 4,
ReactionMaxUserNum: 10,

@ -1793,6 +1793,42 @@ milestones.filter_sort.most_complete = Most complete
milestones.filter_sort.most_issues = Most issues
milestones.filter_sort.least_issues = Least issues
polls = Polls
polls.welcome = Welcome to the Polls.
polls.welcome_desc = Polls let you hear the song of the crowd.
polls.create = Create Poll
polls.create_first_poll = Create the first Poll
polls.create.success = You created the poll '%s'.
polls.cancel = Cancel
polls.modify = Update Poll
polls.operate.edit = Edit
polls.operate.delete = Delete
polls.delete.success = You deleted the poll and all its judgments.
polls.view = View Poll
polls.view.no_judgments = No judgments have been cast yet on this Poll.
polls.new = New Poll
polls.new.subheader = This poll will use Majority Judgment on all the Issues
polls.new.no_permissions = You do not have the required permissions to create a Poll here.
polls.new.archived = This repository is archived. No new Polls may be started.
polls.subject.label = Subject (short, as to fit in a badge)
polls.subject.placeholder = Urgency, Importance, Demand, Enthusiasm…
polls.description.label = Description (rationale, constitutive details, consequences…)
polls.gradation.label = Grades
polls.candidates.label = Candidates
polls.candidates.pool.issues_merges = All Issues and Merge Requests
polls.candidates.pool.issues = All Issues only
polls.candidates.pool.merges = All Merge Requests only
polls.candidates.pool.indices = All of these
polls.candidates.pool.indices.help = Comma-separated list of Issue numbers
polls.edit = Edit Poll
polls.edit.subheader = Should one be allowed to change the subject after the poll has started?
polls.edit.success = You edited the poll '%s' successfully.
polls.modal.deletion.header = Are you sure you want to delete this poll?
polls.modal.deletion.description = This operation CANNOT be undone! It will also delete all attached judgments.
polls.judgments.create.success = Your judgment was recorded. Thank you ! (+0.618 karma)
polls.judgments.update.success = Your judgment was updated.
polls.judgments.delete.success = Your judgment was discarded, and then burnt, and its ashes scattered into lava.
signing.will_sign = This commit will be signed with key "%s"
signing.wont_sign.error = There was an error whilst checking if the commit could be signed
signing.wont_sign.nokey = There is no key available to sign this commit
@ -1987,6 +2023,7 @@ settings.releases_desc = Enable Repository Releases
settings.packages_desc = Enable Repository Packages Registry
settings.projects_desc = Enable Repository Projects
settings.actions_desc = Enable Repository Actions
settings.polls_desc = Enable Repository Polls
settings.admin_settings = Administrator Settings
settings.admin_enable_health_check = Enable Repository Health Checks (git fsck)
settings.admin_code_indexer = Code Indexer

@ -1687,6 +1687,43 @@ signing.wont_sign.approved=La fusion ne sera pas signée car la PR n'a pas appro
signing.wont_sign.not_signed_in=Vous n'êtes pas authentifié
ext_wiki=Accès au wiki externe
ext_wiki.desc=Lier un wiki externe.
polls = Scrutins
polls.welcome = Bienvenue dans les Scrutins.
polls.welcome_desc = Les scrutins portent le chant de la foule.
polls.create = Créer un Scrutin
polls.create_first_poll = Créer un premier Scrutin
polls.create.success = Vous avez créé le scrutin '%s'.
polls.cancel = Annuler
polls.delete.success = Vous avez supprimé le Scrutin.
polls.modify = Mettre à jour le Scrutin
polls.operate.edit = Éditer
polls.operate.delete = Supprimer
polls.view = Voir le Scrutin
polls.view.no_judgments = Aucun Jugement n'a été effectué sur ce Scrutin pour l'instant.
polls.new = Nouveau Scrutin
polls.new.subheader = Ce Scrutin appliquera le Jugement Majoritaire
polls.new.no_permissions = Vous n'avez pas la permission de créer un Scrutin sur ce dépôt.
polls.new.archived = Ce dépôt est archivé. Les Scrutins sont désactivés.
polls.subject.label = Sujet (en un mot, le plus court possible)
polls.subject.placeholder = Urgence, Importance, Demande…
polls.description.label = Description (motivation, détails constitutifs, conséquences…)
polls.gradation.label = Mentions
polls.candidates.label = Candidat⋅es
polls.candidates.pool.issues_merges = Tous les Tickets et Demandes d'Ajout
polls.candidates.pool.issues = Tickets seulement
polls.candidates.pool.merges = Demandes d'Ajout seulement
polls.candidates.pool.indices = Seulement ceux-là
polls.candidates.pool.indices.help = Liste d'identifiants, séparés par des virgules
polls.edit = Éditer le Scrutin
polls.edit.subheader = Devrait-on être autorisé à changer le sujet à postériori ?
polls.edit.success = Vous avez modifié le scrutin '%s' avec succès.
polls.modal.deletion.header = Êtes-vous sûr⋅e de vouloir supprimer ce scrutin ?
polls.modal.deletion.description = Cette opération ne PEUT PAS ÊTRE ANNULÉE. Tous les jugements associés seront supprimés.
polls.judgments.create.success = Votre jugement a été enregistré. Merci !
polls.judgments.update.success = Votre jugement a été mis à jour.
polls.judgments.delete.success = Votre jugement a été rétracté, puis brûlé, et ses cendres dispersées dans un volcan.
wiki=Wiki
wiki.welcome=Bienvenue sur le Wiki.
wiki.welcome_desc=Le wiki vous permet d'écrire ou de partager de la documentation avec vos collaborateurs.
@ -1839,6 +1876,7 @@ settings.pulls.default_delete_branch_after_merge=Supprimer la branche après la
settings.packages_desc=Activer le registre des paquets du dépôt
settings.projects_desc=Activer les projets de dépôt
settings.actions_desc=Activer les actions du dépôt
settings.polls_desc=Activer les scrutins du dépôt
settings.admin_settings=Paramètres administrateur
settings.admin_enable_health_check=Activer les vérifications de santé du dépôt (git fsck)
settings.admin_code_indexer=Indexeur de code

@ -23,6 +23,7 @@ import (
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/organization"
access_model "code.gitea.io/gitea/models/perm/access"
poll_model "code.gitea.io/gitea/models/poll"
project_model "code.gitea.io/gitea/models/project"
pull_model "code.gitea.io/gitea/models/pull"
repo_model "code.gitea.io/gitea/models/repo"
@ -1391,6 +1392,13 @@ func ViewIssue(ctx *context.Context) {
}
}
// Get polls using this issue as candidate.
ctx.Data["Polls"], err = poll_model.GetPollsUsingIssueAsCandidate(issue)
if err != nil {
ctx.ServerError("IssueView.GetPollsUsingIssueAsCandidate", err)
return
}
// Metas.
// Check labels.
labelIDMark := make(container.Set[int64])

@ -0,0 +1,226 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
poll_model "code.gitea.io/gitea/models/poll"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/forms"
)
const (
tplPollsStart base.TplName = "repo/polls/start"
tplPollsIndex base.TplName = "repo/polls/index"
tplPollsView base.TplName = "repo/polls/view"
tplPollsNew base.TplName = "repo/polls/new"
)
// IndexPolls renders a paginated index of all the polls of the repository.
func IndexPolls(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.polls.index.title")
ctx.Data["PageIsPolls"] = true
page := ctx.FormInt("page")
if page <= 1 {
page = 1
}
total, err := poll_model.CountPollsInRepo(ctx.Repo.Repository.ID)
if err != nil {
ctx.ServerError("IndexPolls→CountPollsInRepo", err)
return
}
pager := context.NewPagination(int(total), setting.UI.PollsPagingNum, page, 5)
//pager.AddParam(ctx, "state", "State")
ctx.Data["Page"] = pager
page = pager.Paginater.Current()
polls, err := poll_model.GetPolls(ctx.Repo.Repository.ID, page)
if err != nil {
ctx.ServerError("IndexPolls→GetPolls", err)
return
}
if 0 == len(polls) {
ctx.HTML(200, tplPollsStart)
return
}
for i := range polls {
polls[i].RenderedDescription, err = markdown.RenderString(&markup.RenderContext{
URLPrefix: ctx.Repo.RepoLink,
Metas: ctx.Repo.Repository.ComposeMetas(),
GitRepo: ctx.Repo.GitRepo,
Ctx: ctx,
}, polls[i].Description)
if err != nil {
ctx.ServerError("IndexPolls→RenderString", err)
return
}
}
ctx.Data["Polls"] = polls
ctx.HTML(200, tplPollsIndex)
}
// NewPoll renders the "new poll" page with its form.
func NewPoll(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.polls.new")
ctx.Data["PageIsPolls"] = true
ctx.HTML(200, tplPollsNew)
}
// NewPollPost processes the "new poll" form and redirects.
func NewPollPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.CreatePollForm)
if ctx.HasError() {
NewPoll(ctx)
return
}
ctx.Data["Title"] = ctx.Tr("repo.polls.new")
ctx.Data["PageIsPolls"] = true
candidatesAllowedList := ""
if form.CandidatesPool == "allowed_list" {
candidatesAllowedList = form.CandidatesAllowedList
}
poll, err := poll_model.CreatePoll(&poll_model.CreatePollOptions{
Author: ctx.Doer,
Repo: ctx.Repo.Repository,
Subject: form.Subject,
Description: form.Description,
CandidatesAllowedList: candidatesAllowedList,
})
if err != nil {
ctx.ServerError("NewPollPost→CreatePoll", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.polls.create.success", form.Subject))
ctx.Redirect(poll.Link())
}
// ViewPoll renders a page displaying a single poll.
func ViewPoll(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.polls.view")
ctx.Data["PageIsPolls"] = true
poll, err := poll_model.GetPollByRepoID(ctx.Repo.Repository.ID, ctx.ParamsInt64(":id"))
if nil != err {
if poll_model.IsErrPollNotFound(err) {
ctx.NotFound("", nil)
} else {
ctx.ServerError("ViewPoll→GetPollByRepoID", err)
}
return
}
poll.RenderedDescription, err = markdown.RenderString(&markup.RenderContext{
Ctx: ctx,
URLPrefix: ctx.Repo.RepoLink,
Metas: ctx.Repo.Repository.ComposeMetas(),
}, poll.Description)
ctx.Data["Poll"] = poll
ctx.HTML(200, tplPollsView)
}
// EditPoll renders the form for editing a poll.
func EditPoll(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.polls.edit")
ctx.Data["PageIsPolls"] = true
ctx.Data["PageIsEditPoll"] = true
poll, err := poll_model.GetPollByRepoID(ctx.Repo.Repository.ID, ctx.ParamsInt64(":id"))
if nil != err {
if poll_model.IsErrPollNotFound(err) {
ctx.NotFound("", nil)
} else {
ctx.ServerError("EditPoll→GetPollByRepoID", err)
}
return
}
candidatesPool := "issues_merges"
if poll.CandidatesAllowedList != "" {
candidatesPool = "allowed_list"
}
ctx.Data["subject"] = poll.Subject
ctx.Data["description"] = poll.Description
ctx.Data["candidatesPool"] = candidatesPool
ctx.Data["candidatesAllowedList"] = poll.CandidatesAllowedList
ctx.Data["redirect"] = ctx.FormString("redirect")
ctx.HTML(200, tplPollsNew)
}
// EditPollPost handles the response for editing a poll.
func EditPollPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.CreatePollForm)
ctx.Data["Title"] = ctx.Tr("repo.polls.edit")
ctx.Data["PageIsPolls"] = true
ctx.Data["PageIsEditPoll"] = true
if ctx.HasError() {
ctx.HTML(200, tplPollsNew)
return
}
poll, err := poll_model.GetPollByRepoID(ctx.Repo.Repository.ID, ctx.ParamsInt64(":id"))
if err != nil {
if poll_model.IsErrPollNotFound(err) {
ctx.NotFound("", nil)
} else {
ctx.ServerError("EditPollPost→GetPollByRepoID", err)
}
return
}
poll.Subject = form.Subject
poll.Description = form.Description
poll.CandidatesAllowedList = ""
if form.CandidatesPool == "allowed_list" {
poll.CandidatesAllowedList = form.CandidatesAllowedList
}
if err = poll_model.UpdatePoll(poll); err != nil {
ctx.ServerError("EditPollPost→UpdatePoll", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.polls.edit.success", poll.Subject))
redirect_path := ctx.FormString("redirect")
if redirect_path != "" {
ctx.Redirect(redirect_path)
return
}
//ctx.Redirect(poll.Link()) // nope, panic
ctx.Redirect(ctx.Repo.RepoLink + "/polls")
}
// DeletePoll deletes a poll and redirects to the polls index.
func DeletePoll(ctx *context.Context) {
if err := poll_model.DeletePollByRepoID(ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")); err != nil {
ctx.Flash.Error("DeletePollByRepoID: " + err.Error())
} else {
ctx.Flash.Success(ctx.Tr("repo.polls.delete.success"))
}
ctx.JSON(200, map[string]interface{}{
"redirect": ctx.Repo.RepoLink + "/polls",
})
}

@ -0,0 +1,104 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"path"
issues_model "code.gitea.io/gitea/models/issues"
poll_model "code.gitea.io/gitea/models/poll"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/setting"
)
type CreateJudgmentResponse struct {
Judgment *poll_model.PollJudgment
}
// EmitJudgment creates, updates or deletes a Judgment depending on the parameters.
func EmitJudgment(ctx *context.Context) {
judge := ctx.Doer
grade := uint8(ctx.FormInt("grade")) // 0 if not defined
if grade < 1 {
grade = 0
}
pollId := ctx.ParamsInt64(":id")
candidateID := ctx.FormInt64("candidate")
poll, errP := poll_model.GetPollByRepoID(ctx.Repo.Repository.ID, pollId)
if nil != errP {
ctx.NotFound("EmitJudgment.GetPollByRepoID", errP)
return
}
issue, errI := issues_model.GetIssueByIndex(ctx.Repo.Repository.ID, candidateID)
if nil != errI {
ctx.NotFound("EmitJudgment.GetIssueByIndex", errI)
return
}
if false == poll.AllowsIssueAsCandidate(issue) {
ctx.NotFound("EmitJudgment.AllowsIssueAsCandidate", nil)
return
}
judgment := poll.GetJudgmentOnCandidate(judge, candidateID)
if nil != judgment {
if judgment.Grade == grade {
// Delete a judgment if it exists and is the same as submitted.
// This allows us to click again on the emote to remove our judgment.
errD := poll_model.DeleteJudgment(&poll_model.DeleteJudgmentOptions{
Poll: poll,
Judge: judge,
CandidateID: candidateID,
})
if nil != errD {
ctx.ServerError("EmitJudgment.DeleteJudgment", errD)
return
} else {
ctx.Flash.Success(ctx.Tr("repo.polls.judgments.delete.success"))
}
} else {
// The judgment exists and the grade is different, so Update it.
_, errU := poll_model.UpdateJudgment(&poll_model.UpdateJudgmentOptions{
Poll: poll,
Judge: judge,
Grade: grade,
CandidateID: candidateID,
})
if nil != errU {
ctx.ServerError("EmitJudgment.UpdateJudgment", errU)
return
} else {
ctx.Flash.Success(ctx.Tr("repo.polls.judgments.update.success"))
}
}
} else {
// Create a judgment, since none existed for this candidate and judge.
_, errC := poll_model.CreateJudgment(&poll_model.CreateJudgmentOptions{
Poll: poll,
Judge: judge,
Grade: grade,
CandidateID: candidateID,
})
if nil != errC {
ctx.ServerError("EmitJudgment.EmitJudgment", errC)
return
} else {
ctx.Flash.Success(ctx.Tr("repo.polls.judgments.create.success"))
}
}
redirectPath := ctx.FormTrim("redirect")
ctx.Redirect(path.Join(setting.AppSubURL, redirectPath))
// Nice, but yields too much (passwords, lol)
//ctx.JSON(200, &CreateJudgmentResponse{
// Judgment: judgment,
//})
}

@ -525,6 +525,15 @@ func SettingsPost(ctx *context.Context) {
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeActions)
}
if form.EnablePolls && !unit_model.TypePolls.UnitGlobalDisabled() {
units = append(units, repo_model.RepoUnit{
RepoID: repo.ID,
Type: unit_model.TypePolls,
})
} else if !unit_model.TypePolls.UnitGlobalDisabled() {
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypePolls)
}
if form.EnablePulls && !unit_model.TypePullRequests.UnitGlobalDisabled() {
units = append(units, repo_model.RepoUnit{
RepoID: repo.ID,

@ -971,6 +971,10 @@ func registerRoutes(m *web.Route) {
m.Get("/info", repo.GetIssueInfo)
})
})
m.Group("/polls", func() {
m.Get("", repo.IndexPolls)
m.Get("/{id}", repo.ViewPoll)
}, context.RepoAssignment, context.RepoRef())
}, ignSignIn, context.RepoAssignment, context.UnitTypes()) // for "/{username}/{reponame}" which doesn't require authentication
// Grouping for those endpoints that do require authentication
@ -1060,6 +1064,17 @@ func registerRoutes(m *web.Route) {
m.Post("/{index}/target_branch", repo.UpdatePullRequestTarget)
}, context.RepoMustNotBeArchived())
m.Group("/polls", func() {
m.Combo("/new").
Get(repo.NewPoll).
Post(web.Bind(forms.CreatePollForm{}), repo.NewPollPost)
m.Get("/{id}/edit", repo.EditPoll)
m.Post("/{id}/edit", web.Bind(forms.CreatePollForm{}), repo.EditPollPost)
m.Post("/{id}/delete", repo.DeletePoll)
m.Post("/{id}/judgments", repo.EmitJudgment)
// m.Delete("/{id}/judgments", repo.DeleteJudgment)
}, context.RepoMustNotBeArchived())
m.Group("", func() {
m.Group("", func() {
m.Combo("/_edit/*").Get(repo.EditFile).

@ -0,0 +1,34 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package forms
import (
"net/http"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/web/middleware"
"gitea.com/go-chi/binding"
)
// __________ .__ .__ ___________
// \______ \____ | | | | \_ _____/__________ _____ ______
// | ___/ _ \| | | | | __)/ _ \_ __ \/ \ / ___/
// | | ( <_> ) |_| |__ | \( <_> ) | \/ Y Y \\___ \
// |____| \____/|____/____/ \___ / \____/|__| |__|_| /____ >
// \/ \/ \/
// CreatePollForm is used when creating a new poll.
type CreatePollForm struct {
Subject string `binding:"Required;MaxSize(128)"` // 128 is duplicated in the template
Description string
CandidatesPool string
CandidatesAllowedList string
}
// Validate the poll creation form fields.
func (f *CreatePollForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
ctx := context.GetValidateContext(req)
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}

@ -150,6 +150,7 @@ type RepoSettingForm struct {
EnablePackages bool
EnablePulls bool
EnableActions bool
EnablePolls bool
PullsIgnoreWhitespace bool
PullsAllowMerge bool
PullsAllowRebase bool

@ -227,6 +227,12 @@
</a>
{{end}}
{{if .Permission.CanRead $.UnitTypePolls}}
<a class="{{if .PageIsPolls}}active{{end}} item" href="{{.RepoLink}}/polls">
{{svg "octicon-law" 16}} {{.locale.Tr "repo.polls"}}
</a>
{{end}}
{{if and (.Permission.CanReadAny $.UnitTypePullRequests $.UnitTypeIssues $.UnitTypeReleases) (not .IsEmptyRepo)}}
<a class="{{if .PageIsActivity}}active {{end}}item" href="{{.RepoLink}}/activity">
{{svg "octicon-pulse"}} {{.locale.Tr "repo.activity"}}

@ -41,6 +41,11 @@
{{else}}
<div class="ui green label issue-state-label">{{svg "octicon-issue-opened"}} {{.locale.Tr "repo.issues.open_title"}}</div>
{{end}}
{{range $poll := .Polls}}
{{template "repo/polls/poll_badge" dict "Poll" $poll "CandidateID" $.Issue.Index "Super" $}}
{{end}}
<div class="gt-ml-3">
{{if .Issue.IsPull}}
{{$headHref := .HeadTarget|Escape}}

@ -0,0 +1,63 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository polls">
{{template "repo/header" .}}
<div class="ui container">
<div class="navbar gt-mb-4">
<div></div>{{/* we have nothing to show on the left for now */}}
<div class="group">
{{template "repo/polls/navbar/new" .}}
</div>
</div>
{{template "base/alert" .}}
<div class="poll list milestone-list">
{{range .Polls}}
<li class="item milestone-card">
<h3 id="poll-{{.ID}}" class="flex-text-block gt-m-0">
{{svg "octicon-law" 16}}
<a class="muted" href="{{$.RepoLink}}/polls/{{.ID}}">{{.Subject}}</a>
</h3>
{{if and ($.CanWritePolls) (not $.Repository.IsArchived)}}
<div class="milestone-toolbar">
<div class="group">
</div>
<div class="group">
{{/* What are the purposes of data-id and data-title here ? */}}
<a class="flex-text-inline" href="{{$.Link}}/{{.ID}}/edit?redirect={{$.Link}}{{"?page="}}{{$.Page.Paginater.Current}}{{"#poll-"}}{{.ID}}" data-id="{{.ID}}" data-title="{{.Subject}}">
{{svg "octicon-pencil" 14}}
{{$.locale.Tr "repo.polls.operate.edit"}}
</a>
{{/* href is empty on purpose -- people without js do not want to delete by misclick. */}}
{{/* there might also be a DELETE HTTP Method lurking around, or is it API only? */}}
<a class="flex-text-inline delete-button" href data-url="{{$.Link}}/{{.ID}}/delete" data-id="{{.ID}}">
{{svg "octicon-trash" 14}}
{{$.locale.Tr "repo.polls.operate.delete"}}
</a>
</div>
</div>
{{end}}
{{if .Description}}
<div class="content">
{{.RenderedDescription|Str2html}}
</div>
{{end}}
</li>
{{else}}
<p>{{.locale.Tr "repo.polls.index.nothing"}}</p>
{{if not (.CanWritePolls)}}
<p>{{.locale.Tr "repo.polls.new.no_permissions"}}</p>
{{end}}
{{if .Repository.IsArchived}}
<p>{{.locale.Tr "repo.polls.new.archived"}}</p>
{{end}}
{{end}}
{{template "base/paginate" .}}
</div>
</div>
</div>
{{template "repo/polls/modal/delete" .}}
{{template "base/footer" .}}

@ -0,0 +1,63 @@
{{$merit := .}}
{{/*
Radial representation of a merit profile.
The $merit above is an instance of PollCandidateMeritProfile.
In this SVG, the ViewBox is centered on (0.0), and both axes go from -1 to 1.
*/}}
{{$size := 32}}{{/* size of the SVG (pixels) */}}
{{$delimiter := 0.04}}{{/* breadth of the white line between grades (viewbox referential) */}}
{{$half_gap := 0.38}}{{/* ~asin(GOLDEN_RATIO-1) adjusted for the delimiter - (radians) */}}
{{$half_rest := 2.7616}}{{/* MUST BE (PI - $half_gap) - (radians) */}}
{{$exterior_radius := 0.9167}}{{/* About 11/12 to leave some padding (viewbox referential) */}}
{{$interior_radius := 0.3501}}{{/* exterior_radius/GOLDEN_RATIO (viewbox referential) */}}
<svg
style="transform: rotate(0.25turn);"
width="{{$size}}px"
height="{{$size}}px"
viewBox="-1 -1 2 2"{{/* min_x, min_y, width, height */}}
>
{{range $grade_id, $amount := .Grades}}
{{$angle := $merit.GetGradeAngle $grade_id $half_gap}}
{{$exterior := $merit.GetCirclePoints $grade_id $exterior_radius $half_gap}}
{{$interior := $merit.GetCirclePoints $grade_id $interior_radius $half_gap}}
{{$large_flag := 0}}
{{if gt $angle $half_rest}}
{{$large_flag = 1}}
{{end}}
{{$color := $merit.GetColorCode $grade_id}}
{{/* (M)ove x y */}}
{{/* (A)rc rx ry x-axis-rotation large-arc-flag sweep-flag x y */}}
{{/* (L)ine x y */}}
<path
d="M {{$exterior.Start.X}} {{$exterior.Start.Y}} A {{$exterior_radius}} {{$exterior_radius}} 0 {{$large_flag}} 1 {{$exterior.End.X}} {{$exterior.End.Y}} L {{$interior.End.X}} {{$interior.End.Y}} A {{$interior_radius}} {{$interior_radius}} 0 {{$large_flag}} 0 {{$interior.Start.X}} {{$interior.Start.Y}}"
fill="{{$color}}"
></path>
{{end}}
{{/* Draw white delimiters between grades, for accessibility. */}}
{{/* If we can do this all in one pass, by resizing the arcs, it would be faster. */}}
{{/* Keep in mind that in that case we would need a background color. */}}
{{/* We need a background color anyways, at least a white padding. */}}
{{range $grade_id, $amount := .Grades}}
{{$exterior := $merit.GetCirclePoints $grade_id $exterior_radius $half_gap}}
{{$interior := $merit.GetCirclePoints $grade_id $interior_radius $half_gap}}
{{$color := "#FFFFFF"}}
<path
d="M {{$exterior.End.X}} {{$exterior.End.Y}} L {{$interior.End.X}} {{$interior.End.Y}}"
stroke="{{$color}}"
stroke-width="{{$delimiter}}"
></path>
{{if eq $grade_id 0}}
<path
d="M {{$exterior.Start.X}} {{$exterior.Start.Y}} L {{$interior.Start.X}} {{$interior.Start.Y}}"
stroke="{{$color}}"
stroke-width="{{$delimiter}}"
></path>
{{end}}
{{end}}
</svg>

@ -0,0 +1,13 @@
{{/* Confirmation modal that pops in whenever we try to delete a poll. */}}
{{if .CanWritePolls}}
<div class="ui g-modal-confirm delete modal">
<div class="header">
{{svg "octicon-trash"}}
{{.locale.Tr "repo.polls.modal.deletion.header"}}
</div>
<div class="content">
<p>{{.locale.Tr "repo.polls.modal.deletion.description"}}</p>
</div>
{{template "base/modal_actions_confirm" .}}
</div>
{{end}}

@ -0,0 +1,3 @@
{{if and (.CanWritePolls) (not .Repository.IsArchived)}}
<a class="ui small green button" href="{{$.RepoLink}}/polls/new">{{.locale.Tr "repo.polls.new"}}</a>
{{end}}

@ -0,0 +1,73 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository polls new">
{{template "repo/header" .}}
<div class="ui container">
<h2 class="ui dividing header">
{{if .PageIsEditPoll}}
{{.locale.Tr "repo.polls.edit"}}
<div class="sub header">{{.locale.Tr "repo.polls.edit.subheader"}}</div>
{{else}}
{{.locale.Tr "repo.polls.new"}}
<div class="sub header">{{.locale.Tr "repo.polls.new.subheader"}}</div>
{{end}}
</h2>
{{template "base/alert" .}}
<form class="ui form grid" action="{{.Link}}" method="post">
{{.CsrfTokenHtml}}
<input type="hidden" name="redirect" value="{{.redirect}}">
<div class="twelve wide column">
<div class="field {{if .Err_Title}}error{{end}}">
<label for="form_poll_subject">{{.locale.Tr "repo.polls.subject.label"}}</label>
<input id="form_poll_subject" name="subject"
placeholder="{{.locale.Tr "repo.polls.subject.placeholder"}}"
value="{{.subject}}" autofocus required maxlength="128">
</div>
<div class="field">
<label for="form_poll_description">{{.locale.Tr "repo.polls.description.label"}}</label>
<textarea id="form_poll_description" name="description">{{.description}}</textarea>
</div>
<div class="field disabled">
<label for="form_poll_gradation">{{.locale.Tr "repo.polls.gradation.label"}}</label>
<select id="form_poll_gradation" name="gradation" class="emote">
<option value="emote6-0" selected>😫 😒 😐 😌 😀 😍</option>
<option value="emote6-1">🤮 😒 😐 🙂 😀 🤩</option>
</select>
</div>
<div class="field">
<label>{{.locale.Tr "repo.polls.candidates.label"}}</label>
<label>
<input type="radio" name="candidates_pool" value="issues_merges"{{if not (eq .candidatesPool "allowed_list")}} checked{{end}}>
{{.locale.Tr "repo.polls.candidates.pool.issues_merges"}}
</label>
<label>
<input type="radio" name="candidates_pool" value="allowed_list"{{if eq .candidatesPool "allowed_list"}} checked{{end}}>
{{.locale.Tr "repo.polls.candidates.pool.indices"}} :
<input class="inline-input" type="text" name="candidates_allowed_list"
title="{{.locale.Tr "repo.polls.candidates.pool.indices.help"}}"
value="{{.candidatesAllowedList}}"
placeholder="1, 9, 12">
</label>
</div>
</div>
<div class="ui container">
<div class="ui divider"></div>
<div class="ui right">
{{if .PageIsEditPoll}}
<a class="ui blue basic button" href="{{if .redirect}}{{.redirect}}{{else}}{{.RepoLink}}/polls{{end}}">
{{.locale.Tr "repo.polls.cancel"}}
</a>
<button class="ui green button">
{{.locale.Tr "repo.polls.modify"}}
</button>
{{else}}
<button class="ui green button">
{{.locale.Tr "repo.polls.create"}}
</button>
{{end}}
</div>
</div>
</form>
</div>
</div>
{{template "base/footer" .}}

@ -0,0 +1,72 @@
{{/*
A buttonish label for polls candidates.
No javascript for now. There may be _some_ later to avoid reloading the whole page.
.Poll : the Poll in question
.CandidateID : the Candidate
.Super : $ of parent template
*/}}
{{$pollResult := .Poll.GetResult}}
{{if $pollResult}}
{{$candidateID := .CandidateID}}
{{$pollID := .Poll.ID}}
{{$pollCandidateResult := ($pollResult.GetResultOfCandidate $candidateID)}}
{{$medianColorWord := "grey"}}
{{if $pollCandidateResult}}
{{$medianColorWord = (.Poll.GetGradeColorWord $pollCandidateResult.Analysis.MedianGrade)}}
{{end}}
{{$judgment := ""}}
{{if $.Super.IsSigned}}
{{$judgment = (.Poll.GetJudgmentOnCandidate $.Super.SignedUser $candidateID)}}
{{end}}
{{$userGrade := -1}}
{{if $judgment}}
{{$userGrade = $judgment.Grade}}
{{end}}
{{/* Wrapper for the :hover */}}
<div class="ui poll-badge" tabindex="0">
<div class="ui label {{$medianColorWord}} issue-state-label">
{{svg "octicon-law"}}
{{.Poll.Subject}}
{{if $pollCandidateResult -}}
{{$pollCandidateResult.Rank}}
{{- end}}
</div>
{{if $.Super.IsSigned}}
<div class="judgment-forms">
{{range $grade, $icon := .Poll.GetGradationList}}
<form class="judgment-form {{if (eq $grade $userGrade)}}selected{{end}}" action="{{$.Super.AppSubUrl}}/{{$.Super.RepoRelPath}}/polls/{{$pollID}}/judgments" method="post">
{{$.Super.CsrfTokenHtml}}
<input type="hidden" name="redirect" value="{{$.Super.AppSubUrl}}/{{$.Super.RepoRelPath}}/issues/{{$candidateID}}">
<input type="hidden" name="grade" value="{{$grade}}">
<input type="hidden" name="candidate" value="{{$candidateID}}">
{{if $pollCandidateResult}}
{{if eq $pollCandidateResult.Analysis.MedianGrade $grade}}
<div class="background-merit-profile">
{{template "repo/polls/merit_radial" ($pollResult.GetMaritProfileOfCandidate $candidateID)}}
</div>
{{end}}
{{end}}
{{/* Add the emote AFTER the profile or the "absolute" CSS property of the profile is offset (somehow) */}}
<input class="emote" type="submit" value="{{$icon}}">
</form>
{{end}}
</div>
{{else}}
{{/* We want a dummy element here so that the .ui.label above is not :last-child, or its margin-right vanishes */}}
<span></span>
{{/* Instead, it _could_ be a message to entice to login ? */}}
{{/*<div class="judgment-forms">*/}}
{{/* <p><em>Login to judge "{{.Poll.Subject}}" of this Issue.</em></p> */}}
{{/*</div>*/}}
{{end}}
</div>
{{end}}

@ -0,0 +1,15 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository polls start">
{{template "repo/header" .}}
<div class="ui container">
<div class="ui segment center gt-py-5">
{{svg "octicon-law" 48}}
<h2>{{.locale.Tr "repo.polls.welcome"}}</h2>
<p>{{.locale.Tr "repo.polls.welcome_desc"}}</p>
{{if and .CanWritePolls (not .Repository.IsMirror)}}
<a class="ui green button" href="{{$.Link}}/new">{{.locale.Tr "repo.polls.create_first_poll"}}</a>
{{end}}
</div>
</div>
</div>
{{template "base/footer" .}}

@ -0,0 +1,74 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository polls view">
{{template "repo/header" .}}
<div class="ui container">
<div class="navbar gt-mb-4">
<div></div>{{/* we have nothing to show on the left for now */}}
<div class="group">
{{template "repo/polls/navbar/new" .}}
</div>
</div>
<div class="ui divider"></div>
{{template "base/alert" .}}
<div class="ui two column stackable grid">
<div class="column">
<h2>{{.Poll.Subject}}</h2>
{{if .Poll.RenderedDescription}}
<div class="render-content markdown">
{{.Poll.RenderedDescription|Str2html}}
</div>
{{end}}
</div>
<div class="column right aligned">
{{if and ($.CanWritePolls) (not $.Repository.IsArchived)}}
<div class="ui compact mini menu">
{{/* .Poll.Link() fails, in the redirect, somehow? Is Repo not hydrated? */}}
<a class="item" href="{{$.RepoLink}}/polls/{{.Poll.ID}}/edit?redirect={{$.RepoLink}}/polls/{{.Poll.ID}}">
{{svg "octicon-pencil"}}
<span class="gt-mx-3">{{.locale.Tr "repo.polls.operate.edit"}}</span>
</a>
<a class="item delete-button" href="#" data-url="{{$.RepoLink}}/polls/{{.Poll.ID}}/delete" data-id="{{.Poll.ID}}">
{{svg "octicon-trash"}}
<span class="gt-mx-3">{{.locale.Tr "repo.polls.operate.delete"}}</span>
</a>
</div>
{{end}}
</div>
</div>
<div class="ui divider"></div>
{{$pollResult := .Poll.GetResult}}
<ul class="candidates issue list">
{{range $key, $proposalResult := $pollResult.ProposalsSorted}}
{{$candidateID := $.Poll.GetIdOfCandidateAtIndex $proposalResult.Index}}
<li class="item">
{{template "repo/polls/merit_radial" ($pollResult.GetMaritProfileOfCandidate $candidateID)}}
&nbsp;
{{$proposalResult.Rank}}
&mdash;
&nbsp;
<a href="{{$.AppSubUrl}}/{{$.RepoRelPath}}/issues/{{$candidateID}}">
{{($.Poll.GetNameOfCandidate $candidateID)}}
(#{{$candidateID}})
</a>
</li>
{{else}}
<li>
<em>{{.locale.Tr "repo.polls.view.no_judgments"}}</em>
</li>
{{end}}
</ul>
</div>
</div>
{{template "repo/polls/modal/delete" .}}
{{template "base/footer" .}}

@ -442,6 +442,19 @@
<div class="ui divider"></div>
{{$isPollsEnabled := .Repository.UnitEnabled $.Context $.UnitTypePolls}}
<div class="inline field">
<label>{{.locale.Tr "repo.polls"}}</label>
{{if .UnitTypePolls.UnitGlobalDisabled}}
<div class="ui checkbox disabled" data-tooltip-content="{{.locale.Tr "repo.unit_disabled"}}">
{{else}}
<div class="ui checkbox">
{{end}}
<input class="enable-system" name="enable_polls" type="checkbox" {{if $isPollsEnabled}}checked{{end}}>
<label>{{.locale.Tr "repo.settings.polls_desc"}}</label>
</div>
</div>
{{$isProjectsEnabled := .Repository.UnitEnabled $.Context $.UnitTypeProjects}}
<div class="inline field">
<label>{{.locale.Tr "repo.project_board"}}</label>

@ -45,6 +45,7 @@
@import "./repo/issue-list.css";
@import "./repo/list-header.css";
@import "./repo/linebutton.css";
@import "./repo/poll.css";
@import "./repo/wiki.css";
@import "./editor/fileeditor.css";

@ -0,0 +1,214 @@
/*
__________ .__ .__
\______ \____ | | | | ______
| ___/ _ \| | | | / ___/
| | ( <_> | |_| |__\___ \
|____| \____/|____|____/____ >
\/
*/
:root {
--color-denied: darkred;
}
/* Poll Badges require tweaking on mobile to stack vertically ; improve at will */
/*@mobile: ~"only screen and (max-width: 600px)";*/
/*
___________ __
\__ _____ _ __ ____ _____ | | __ ______
| | \ \/ \/ _/ __ \\__ \ | |/ // ___/
| | \ /\ ___/ / __ \| < \___ \
|____| \/\_/ \___ (____ |__|_ /____ >
\/ \/ \/ \/
*/
/*.ui.label {*/
/* padding-top: 0.6em;*/
/* padding-bottom: 0.6em;*/
/*}*/
/*.ui.form input[type="text"].inline-input {*/
/* display: inline-block;*/
/* width: initial;*/
/* line-height: 1em;*/
/* height: 1.62em;*/
/* padding-left: 0.3em;*/
/* padding-right: 0.3em;*/
/*}*/
.issue-title-meta {
/* When there are many badges we want to wrap, or it will squish or overflow. */
flex-wrap: wrap;
}
/*
________ .___
/ _________________ __| _/____ ______
/ \ __\_ __ \__ \ / __ _/ __ \ / ___/
\ \_\ | | \// __ \/ /_/ \ ___/ \___ \
\______ |__| (____ \____ |\___ /____ >
\/ \/ \/ \/ \/
*/
.emote {
font-family: "Source Code Pro", Consolas, monaco, monospace !important;
}
/*
__________ .__ .__ __________ .___
\______ \____ | | | | \______ \_____ __| _/ ____ ____
| ___/ _ \| | | | | | _/\__ \ / __ | / ___\_/ __ \
| | ( <_> ) |_| |__ | | \ / __ \_/ /_/ |/ /_/ > ___/
|____| \____/|____/____/ |______ /(____ /\____ |\___ / \___ >
\/ \/ \/_____/ \/
*/
.poll-badge {
display: flex;
flex-wrap: nowrap;
white-space: nowrap;
margin-left: .14285714em;
/*margin-right: .14285714em; nope: the inner label handles this */
margin-top: .14285714em;
margin-bottom: .14285714em;
/*@media @mobile {*/
/* display: block;*/
/*}*/
}
/* Opinion: outlines should NOT be none on ui buttons. */
.poll-badge,
.poll-badge input {
outline-width: 2px;
outline-offset: -2px;
}
.poll-badge .ui.label {
flex-wrap: nowrap;
}
.poll-badge .judgment-forms {
display: none;
position: relative;
top: 0;
left: -90px; /* keep as fallback */
opacity: 0.0; /* keep as fallback */
margin: 0;
/* Since transitions won't play with a change of display, we use animation. */
/*transition: left 0.4s ease-out 0s, opacity 0.3s ease-out 0s;*/
animation: fade_in_judgment_forms 0.4s ease-out;
}
.poll-badge:focus-within .judgment-forms,
.poll-badge:focus .judgment-forms,
.poll-badge:hover .judgment-forms {
display: flex;
left: -9px;
opacity: 1.0;
padding-left: 1em;
align-items: center;
}
@keyframes fade_in_judgment_forms {
0% {
pointer-events: none;
opacity: 0;
left: -150px;
transform: scale(0.2);
}
95% {
transform: scale(1.0);
}
99% {
pointer-events: none;
}
100% {
pointer-events: initial;
opacity: 1;
left: -9px;
}
}
.judgment-form {
display: inline-block;
position: relative;
margin: 0;
padding: 0;
}
.judgment-form input.emote {
position: relative;
/* Using both width and height is convenient when aligning the background merit profile */
width: 26px;
height: 26px;
padding: 0;
margin: 0;
border: 0;
font-size: 1.5em;
background: none;
transition: 0.3s filter, 0.2s transform;
cursor: pointer;
z-index: 2;
}
.judgment-form:not(.selected) input.emote {
filter: grayscale(1.0);
}
.judgment-form input.emote:hover,
.judgment-form input.emote:focus {
background: none;
filter: grayscale(0.0);
transform: scale(1.1);
}
.judgment-form.selected:focus-within::after,
.judgment-form.selected:hover::after {
content: "×";
pointer-events: none;
position: absolute;
top: 0;
left: 0;
color: var(--color-denied);
opacity: 0.8;
font-size: 3em;
z-index: 3;
line-height: 50%;
}
/*
_____ .__ __ __________ _____.__.__
/ \ ___________|___/ |_\______ _______ _____/ ____|__| | ____
/ \ / \_/ __ \_ __ | \ __ | ___\_ __ \/ _ \ __\| | | _/ __ \
/ Y \ ___/| | \| || | | | | | \( <_> | | | | |_\ ___/
\____|__ /\___ |__| |__||__| |____| |__| \____/|__| |__|____/\___ >
\/ \/ \/
*/
.background-merit-profile {
position: absolute;
top: -3.6px;
left: -3px;
z-index: 1;
}
/*
_________ .__.__ .___ __
\_ ___ \_____ ____ __| _|__| __| ______ _/ |_ ____ ______
/ \ \/\__ \ / \ / __ || |/ __ |\__ \\ ___/ __ \ / ___/
\ \____/ __ \| | / /_/ || / /_/ | / __ \| | \ ___/ \___ \
\______ (____ |___| \____ ||__\____ |(____ |__| \___ /____ >
\/ \/ \/ \/ \/ \/ \/ \/
*/
ul.candidates.list li {
display: flex;
align-items: center; /* align vertically */
}
Loading…
Cancel
Save