diff options
75 files changed, 3569 insertions, 58 deletions
diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index f8c631ee07..a0aed626ed 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -11,6 +11,11 @@ RUN_USER = git ; Either "dev", "prod" or "test", default is "dev" RUN_MODE = dev +[project] +; Default templates for project boards +PROJECT_BOARD_BASIC_KANBAN_TYPE = To Do, In Progress, Done +PROJECT_BOARD_BUG_TRIAGE_TYPE = Needs Triage, High Priority, Low Priority, Closed + [repository] ROOT = SCRIPT_TYPE = bash @@ -48,11 +53,11 @@ ENABLE_PUSH_CREATE_USER = false ENABLE_PUSH_CREATE_ORG = false ; Comma separated list of globally disabled repo units. Allowed values: repo.issues, repo.ext_issues, repo.pulls, repo.wiki, repo.ext_wiki DISABLED_REPO_UNITS = -; Comma separated list of default repo units. Allowed values: repo.code, repo.releases, repo.issues, repo.pulls, repo.wiki. +; Comma separated list of default repo units. Allowed values: repo.code, repo.releases, repo.issues, repo.pulls, repo.wiki, repo.projects. ; 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 +DEFAULT_REPO_UNITS = repo.code,repo.releases,repo.issues,repo.pulls,repo.wiki,repo.projects ; Prefix archive files by placing them in a directory named after the repository PREFIX_ARCHIVE_FILES = true ; Disable the creation of new mirrors. Pre-existing mirrors remain valid. diff --git a/docs/content/doc/features/comparison.en-us.md b/docs/content/doc/features/comparison.en-us.md index dab1ee7eaf..8e47b0224e 100644 --- a/docs/content/doc/features/comparison.en-us.md +++ b/docs/content/doc/features/comparison.en-us.md @@ -84,7 +84,7 @@ _Symbols used in table:_ | Comment reactions | ✓ | ✘ | ✓ | ✓ | ✓ | ✘ | ✘ | | Lock Discussion | ✓ | ✘ | ✓ | ✓ | ✓ | ✘ | ✘ | | Batch issue handling | ✓ | ✘ | ✓ | ✓ | ✓ | ✘ | ✘ | -| Issue Boards | [✘](https://github.com/go-gitea/gitea/issues/3476) | ✘ | ✘ | ✓ | ✓ | ✘ | ✘ | +| Issue Boards | [✓](https://github.com/go-gitea/gitea/pull/8346) | ✘ | ✘ | ✓ | ✓ | ✘ | ✘ | | Create new branches from issues | ✘ | ✘ | ✘ | ✓ | ✓ | ✘ | ✘ | | Issue search | ✓ | ✘ | ✓ | ✓ | ✓ | ✓ | ✘ | | Global issue search | [✘](https://github.com/go-gitea/gitea/issues/2434) | ✘ | ✓ | ✓ | ✓ | ✓ | ✘ | diff --git a/integrations/gitea-repositories-meta/user5/repo4.git/HEAD b/integrations/gitea-repositories-meta/user5/repo4.git/HEAD new file mode 100644 index 0000000000..cb089cd89a --- /dev/null +++ b/integrations/gitea-repositories-meta/user5/repo4.git/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/integrations/gitea-repositories-meta/user5/repo4.git/config b/integrations/gitea-repositories-meta/user5/repo4.git/config new file mode 100644 index 0000000000..07d359d07c --- /dev/null +++ b/integrations/gitea-repositories-meta/user5/repo4.git/config @@ -0,0 +1,4 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true diff --git a/integrations/gitea-repositories-meta/user5/repo4.git/description b/integrations/gitea-repositories-meta/user5/repo4.git/description new file mode 100644 index 0000000000..498b267a8c --- /dev/null +++ b/integrations/gitea-repositories-meta/user5/repo4.git/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/integrations/gitea-repositories-meta/user5/repo4.git/hooks/applypatch-msg.sample b/integrations/gitea-repositories-meta/user5/repo4.git/hooks/applypatch-msg.sample new file mode 100755 index 0000000000..a5d7b84a67 --- /dev/null +++ b/integrations/gitea-repositories-meta/user5/repo4.git/hooks/applypatch-msg.sample @@ -0,0 +1,15 @@ +#!/bin/sh +# +# An example hook script to check the commit log message taken by +# applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. The hook is +# allowed to edit the commit message file. +# +# To enable this hook, rename this file to "applypatch-msg". + +. git-sh-setup +commitmsg="$(git rev-parse --git-path hooks/commit-msg)" +test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"} +: diff --git a/integrations/gitea-repositories-meta/user5/repo4.git/hooks/commit-msg.sample b/integrations/gitea-repositories-meta/user5/repo4.git/hooks/commit-msg.sample new file mode 100755 index 0000000000..b58d1184a9 --- /dev/null +++ b/integrations/gitea-repositories-meta/user5/repo4.git/hooks/commit-msg.sample @@ -0,0 +1,24 @@ +#!/bin/sh +# +# An example hook script to check the commit log message. +# Called by "git commit" with one argument, the name of the file +# that has the commit message. The hook should exit with non-zero +# status after issuing an appropriate message if it wants to stop the +# commit. The hook is allowed to edit the commit message file. +# +# To enable this hook, rename this file to "commit-msg". + +# Uncomment the below to add a Signed-off-by line to the message. +# Doing this in a hook is a bad idea in general, but the prepare-commit-msg +# hook is more suited to it. +# +# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" + +# This example catches duplicate Signed-off-by lines. + +test "" = "$(grep '^Signed-off-by: ' "$1" | + sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { + echo >&2 Duplicate Signed-off-by lines. + exit 1 +} diff --git a/integrations/gitea-repositories-meta/user5/repo4.git/hooks/fsmonitor-watchman.sample b/integrations/gitea-repositories-meta/user5/repo4.git/hooks/fsmonitor-watchman.sample new file mode 100755 index 0000000000..14ed0aa42d --- /dev/null +++ b/integrations/gitea-repositories-meta/user5/repo4.git/hooks/fsmonitor-watchman.sample @@ -0,0 +1,173 @@ +#!/usr/bin/perl + +use strict; +use warnings; +use IPC::Open2; + +# An example hook script to integrate Watchman +# (https://facebook.github.io/watchman/) with git to speed up detecting +# new and modified files. +# +# The hook is passed a version (currently 2) and last update token +# formatted as a string and outputs to stdout a new update token and +# all files that have been modified since the update token. Paths must +# be relative to the root of the working tree and separated by a single NUL. +# +# To enable this hook, rename this file to "query-watchman" and set +# 'git config core.fsmonitor .git/hooks/query-watchman' +# +my ($version, $last_update_token) = @ARGV; + +# Uncomment for debugging +# print STDERR "$0 $version $last_update_token\n"; + +# Check the hook interface version +if ($version ne 2) { + die "Unsupported query-fsmonitor hook version '$version'.\n" . + "Falling back to scanning...\n"; +} + +my $git_work_tree = get_working_dir(); + +my $retry = 1; + +my $json_pkg; +eval { + require JSON::XS; + $json_pkg = "JSON::XS"; + 1; +} or do { + require JSON::PP; + $json_pkg = "JSON::PP"; +}; + +launch_watchman(); + +sub launch_watchman { + my $o = watchman_query(); + if (is_work_tree_watched($o)) { + output_result($o->{clock}, @{$o->{files}}); + } +} + +sub output_result { + my ($clockid, @files) = @_; + + # Uncomment for debugging watchman output + # open (my $fh, ">", ".git/watchman-output.out"); + # binmode $fh, ":utf8"; + # print $fh "$clockid\n@files\n"; + # close $fh; + + binmode STDOUT, ":utf8"; + print $clockid; + print "\0"; + local $, = "\0"; + print @files; +} + +sub watchman_clock { + my $response = qx/watchman clock "$git_work_tree"/; + die "Failed to get clock id on '$git_work_tree'.\n" . + "Falling back to scanning...\n" if $? != 0; + + return $json_pkg->new->utf8->decode($response); +} + +sub watchman_query { + my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty') + or die "open2() failed: $!\n" . + "Falling back to scanning...\n"; + + # In the query expression below we're asking for names of files that + # changed since $last_update_token but not from the .git folder. + # + # To accomplish this, we're using the "since" generator to use the + # recency index to select candidate nodes and "fields" to limit the + # output to file names only. Then we're using the "expression" term to + # further constrain the results. + if (substr($last_update_token, 0, 1) eq "c") { + $last_update_token = "\"$last_update_token\""; + } + my $query = <<" END"; + ["query", "$git_work_tree", { + "since": $last_update_token, + "fields": ["name"], + "expression": ["not", ["dirname", ".git"]] + }] + END + + # Uncomment for debugging the watchman query + # open (my $fh, ">", ".git/watchman-query.json"); + # print $fh $query; + # close $fh; + + print CHLD_IN $query; + close CHLD_IN; + my $response = do {local $/; <CHLD_OUT>}; + + # Uncomment for debugging the watch response + # open ($fh, ">", ".git/watchman-response.json"); + # print $fh $response; + # close $fh; + + die "Watchman: command returned no output.\n" . + "Falling back to scanning...\n" if $response eq ""; + die "Watchman: command returned invalid output: $response\n" . + "Falling back to scanning...\n" unless $response =~ /^\{/; + + return $json_pkg->new->utf8->decode($response); +} + +sub is_work_tree_watched { + my ($output) = @_; + my $error = $output->{error}; + if ($retry > 0 and $error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) { + $retry--; + my $response = qx/watchman watch "$git_work_tree"/; + die "Failed to make watchman watch '$git_work_tree'.\n" . + "Falling back to scanning...\n" if $? != 0; + $output = $json_pkg->new->utf8->decode($response); + $error = $output->{error}; + die "Watchman: $error.\n" . + "Falling back to scanning...\n" if $error; + + # Uncomment for debugging watchman output + # open (my $fh, ">", ".git/watchman-output.out"); + # close $fh; + + # Watchman will always return all files on the first query so + # return the fast "everything is dirty" flag to git and do the + # Watchman query just to get it over with now so we won't pay + # the cost in git to look up each individual file. + my $o = watchman_clock(); + $error = $output->{error}; + + die "Watchman: $error.\n" . + "Falling back to scanning...\n" if $error; + + output_result($o->{clock}, ("/")); + $last_update_token = $o->{clock}; + + eval { launch_watchman() }; + return 0; + } + + die "Watchman: $error.\n" . + "Falling back to scanning...\n" if $error; + + return 1; +} + +sub get_working_dir { + my $working_dir; + if ($^O =~ 'msys' || $^O =~ 'cygwin') { + $working_dir = Win32::GetCwd(); + $working_dir =~ tr/\\/\//; + } else { + require Cwd; + $working_dir = Cwd::cwd(); + } + + return $working_dir; +} diff --git a/integrations/gitea-repositories-meta/user5/repo4.git/hooks/post-update.sample b/integrations/gitea-repositories-meta/user5/repo4.git/hooks/post-update.sample new file mode 100755 index 0000000000..ec17ec1939 --- /dev/null +++ b/integrations/gitea-repositories-meta/user5/repo4.git/hooks/post-update.sample @@ -0,0 +1,8 @@ +#!/bin/sh +# +# An example hook script to prepare a packed repository for use over +# dumb transports. +# +# To enable this hook, rename this file to "post-update". + +exec git update-server-info diff --git a/integrations/gitea-repositories-meta/user5/repo4.git/hooks/pre-applypatch.sample b/integrations/gitea-repositories-meta/user5/repo4.git/hooks/pre-applypatch.sample new file mode 100755 index 0000000000..4142082bcb --- /dev/null +++ b/integrations/gitea-repositories-meta/user5/repo4.git/hooks/pre-applypatch.sample @@ -0,0 +1,14 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed +# by applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-applypatch". + +. git-sh-setup +precommit="$(git rev-parse --git-path hooks/pre-commit)" +test -x "$precommit" && exec "$precommit" ${1+"$@"} +: diff --git a/integrations/gitea-repositories-meta/user5/repo4.git/hooks/pre-commit.sample b/integrations/gitea-repositories-meta/user5/repo4.git/hooks/pre-commit.sample new file mode 100755 index 0000000000..e144712c85 --- /dev/null +++ b/integrations/gitea-repositories-meta/user5/repo4.git/hooks/pre-commit.sample @@ -0,0 +1,49 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed. +# Called by "git commit" with no arguments. The hook should +# exit with non-zero status after issuing an appropriate message if +# it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-commit". + +if git rev-parse --verify HEAD >/dev/null 2>&1 +then + against=HEAD +else + # Initial commit: diff against an empty tree object + against=$(git hash-object -t tree /dev/null) +fi + +# If you want to allow non-ASCII filenames set this variable to true. +allownonascii=$(git config --type=bool hooks.allownonascii) + +# Redirect output to stderr. +exec 1>&2 + +# Cross platform projects tend to avoid non-ASCII filenames; prevent +# them from being added to the repository. We exploit the fact that the +# printable range starts at the space character and ends with tilde. +if [ "$allownonascii" != "true" ] && + # Note that the use of brackets around a tr range is ok here, (it's + # even required, for portability to Solaris 10's /usr/bin/tr), since + # the square bracket bytes happen to fall in the designated range. + test $(git diff --cached --name-only --diff-filter=A -z $against | + LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 +then + cat <<\EOF +Error: Attempt to add a non-ASCII file name. + +This can cause problems if you want to work with people on other platforms. + +To be portable it is advisable to rename the file. + +If you know what you are doing you can disable this check using: + + git config hooks.allownonascii true +EOF + exit 1 +fi + +# If there are whitespace errors, print the offending file names and fail. +exec git diff-index --check --cached $against -- diff --git a/integrations/gitea-repositories-meta/user5/repo4.git/hooks/pre-merge-commit.sample b/integrations/gitea-repositories-meta/user5/repo4.git/hooks/pre-merge-commit.sample new file mode 100755 index 0000000000..399eab1924 --- /dev/null +++ b/integrations/gitea-repositories-meta/user5/repo4.git/hooks/pre-merge-commit.sample @@ -0,0 +1,13 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed. +# Called by "git merge" with no arguments. The hook should +# exit with non-zero status after issuing an appropriate message to +# stderr if it wants to stop the merge commit. +# +# To enable this hook, rename this file to "pre-merge-commit". + +. git-sh-setup +test -x "$GIT_DIR/hooks/pre-commit" && + exec "$GIT_DIR/hooks/pre-commit" +: diff --git a/integrations/gitea-repositories-meta/user5/repo4.git/hooks/pre-push.sample b/integrations/gitea-repositories-meta/user5/repo4.git/hooks/pre-push.sample new file mode 100755 index 0000000000..6187dbf439 --- /dev/null +++ b/integrations/gitea-repositories-meta/user5/repo4.git/hooks/pre-push.sample @@ -0,0 +1,53 @@ +#!/bin/sh + +# An example hook script to verify what is about to be pushed. Called by "git +# push" after it has checked the remote status, but before anything has been +# pushed. If this script exits with a non-zero status nothing will be pushed. +# +# This hook is called with the following parameters: +# +# $1 -- Name of the remote to which the push is being done +# $2 -- URL to which the push is being done +# +# If pushing without using a named remote those arguments will be equal. +# +# Information about the commits which are being pushed is supplied as lines to +# the standard input in the form: +# +# <local ref> <local sha1> <remote ref> <remote sha1> +# +# This sample shows how to prevent push of commits where the log message starts +# with "WIP" (work in progress). + +remote="$1" +url="$2" + +z40=0000000000000000000000000000000000000000 + +while read local_ref local_sha remote_ref remote_sha +do + if [ "$local_sha" = $z40 ] + then + # Handle delete + : + else + if [ "$remote_sha" = $z40 ] + then + # New branch, examine all commits + range="$local_sha" + else + # Update to existing branch, examine new commits + range="$remote_sha..$local_sha" + fi + + # Check for WIP commit + commit=`git rev-list -n 1 --grep '^WIP' "$range"` + if [ -n "$commit" ] + then + echo >&2 "Found WIP commit in $local_ref, not pushing" + exit 1 + fi + fi +done + +exit 0 diff --git a/integrations/gitea-repositories-meta/user5/repo4.git/hooks/pre-rebase.sample b/integrations/gitea-repositories-meta/user5/repo4.git/hooks/pre-rebase.sample new file mode 100755 index 0000000000..6cbef5c370 --- /dev/null +++ b/integrations/gitea-repositories-meta/user5/repo4.git/hooks/pre-rebase.sample @@ -0,0 +1,169 @@ +#!/bin/sh +# +# Copyright (c) 2006, 2008 Junio C Hamano +# +# The "pre-rebase" hook is run just before "git rebase" starts doing +# its job, and can prevent the command from running by exiting with +# non-zero status. +# +# The hook is called with the following parameters: +# +# $1 -- the upstream the series was forked from. +# $2 -- the branch being rebased (or empty when rebasing the current branch). +# +# This sample shows how to prevent topic branches that are already +# merged to 'next' branch from getting rebased, because allowing it +# would result in rebasing already published history. + +publish=next +basebranch="$1" +if test "$#" = 2 +then + topic="refs/heads/$2" +else + topic=`git symbolic-ref HEAD` || + exit 0 ;# we do not interrupt rebasing detached HEAD +fi + +case "$topic" in +refs/heads/??/*) + ;; +*) + exit 0 ;# we do not interrupt others. + ;; +esac + +# Now we are dealing with a topic branch being rebased +# on top of master. Is it OK to rebase it? + +# Does the topic really exist? +git show-ref -q "$topic" || { + echo >&2 "No such branch $topic" + exit 1 +} + +# Is topic fully merged to master? +not_in_master=`git rev-list --pretty=oneline ^master "$topic"` +if test -z "$not_in_master" +then + echo >&2 "$topic is fully merged to master; better remove it." + exit 1 ;# we could allow it, but there is no point. +fi + +# Is topic ever merged to next? If so you should not be rebasing it. +only_next_1=`git rev-list ^master "^$topic" ${publish} | sort` +only_next_2=`git rev-list ^master ${publish} | sort` +if test "$only_next_1" = "$only_next_2" +then + not_in_topic=`git rev-list "^$topic" master` + if test -z "$not_in_topic" + then + echo >&2 "$topic is already up to date with master" + exit 1 ;# we could allow it, but there is no point. + else + exit 0 + fi +else + not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"` + /usr/bin/perl -e ' + my $topic = $ARGV[0]; + my $msg = "* $topic has commits already merged to public branch:\n"; + my (%not_in_next) = map { + /^([0-9a-f]+) /; + ($1 => 1); + } split(/\n/, $ARGV[1]); + for my $elem (map { + /^([0-9a-f]+) (.*)$/; + [$1 => $2]; + } split(/\n/, $ARGV[2])) { + if (!exists $not_in_next{$elem->[0]}) { + if ($msg) { + print STDERR $msg; + undef $msg; + } + print STDERR " $elem->[1]\n"; + } + } + ' "$topic" "$not_in_next" "$not_in_master" + exit 1 +fi + +<<\DOC_END + +This sample hook safeguards topic branches that have been +published from being rewound. + +The workflow assumed here is: + + * Once a topic branch forks from "master", "master" is never + merged into it again (either directly or indirectly). + + * Once a topic branch is fully cooked and merged into "master", + it is deleted. If you need to build on top of it to correct + earlier mistakes, a new topic branch is created by forking at + the tip of the "master". This is not strictly necessary, but + it makes it easier to keep your history simple. + + * Whenever you need to test or publish your changes to topic + branches, merge them into "next" branch. + +The script, being an example, hardcodes the publish branch name +to be "next", but it is trivial to make it configurable via +$GIT_DIR/config mechanism. + +With this workflow, you would want to know: + +(1) ... if a topic branch has ever been merged to "next". Young + topic branches can have stupid mistakes you would rather + clean up before publishing, and things that have not been + merged into other branches can be easily rebased without + affecting other people. But once it is published, you would + not want to rewind it. + +(2) ... if a topic branch has been fully merged to "master". + Then you can delete it. More importantly, you should not + build on top of it -- other people may already want to + change things related to the topic as patches against your + "master", so if you need further changes, it is better to + fork the topic (perhaps with the same name) afresh from the + tip of "master". + +Let's look at this example: + + o---o---o---o---o---o---o---o---o---o "next" + / / / / + / a---a---b A / / + / / / / + / / c---c---c---c B / + / / / \ / + / / / b---b C \ / + / / / / \ / + ---o---o---o---o---o---o---o---o---o---o---o "master" + + +A, B and C are topic branches. + + * A has one fix since it was merged up to "next". + + * B has finished. It has been fully merged up to "master" and "next", + and is ready to be deleted. + + * C has not merged to "next" at all. + +We would want to allow C to be rebased, refuse A, and encourage +B to be deleted. + +To compute (1): + + git rev-list ^master ^topic next + git rev-list ^master next + + if these match, topic has not merged in next at all. + +To compute (2): + + git rev-list master..topic + + if this is empty, it is fully merged to "master". + +DOC_END diff --git a/integrations/gitea-repositories-meta/user5/repo4.git/hooks/pre-receive.sample b/integrations/gitea-repositories-meta/user5/repo4.git/hooks/pre-receive.sample new file mode 100755 index 0000000000..a1fd29ec14 --- /dev/null +++ b/integrations/gitea-repositories-meta/user5/repo4.git/hooks/pre-receive.sample @@ -0,0 +1,24 @@ +#!/bin/sh +# +# An example hook script to make use of push options. +# The example simply echoes all push options that start with 'echoback=' +# and rejects all pushes when the "reject" push option is used. +# +# To enable this hook, rename this file to "pre-receive". + +if test -n "$GIT_PUSH_OPTION_COUNT" +then + i=0 + while test "$i" -lt "$GIT_PUSH_OPTION_COUNT" + do + eval "value=\$GIT_PUSH_OPTION_$i" + case "$value" in + echoback=*) + echo "echo from the pre-receive-hook: ${value#*=}" >&2 + ;; + reject) + exit 1 + esac + i=$((i + 1)) + done +fi diff --git a/integrations/gitea-repositories-meta/user5/repo4.git/hooks/prepare-commit-msg.sample b/integrations/gitea-repositories-meta/user5/repo4.git/hooks/prepare-commit-msg.sample new file mode 100755 index 0000000000..10fa14c5ab --- /dev/null +++ b/integrations/gitea-repositories-meta/user5/repo4.git/hooks/prepare-commit-msg.sample @@ -0,0 +1,42 @@ +#!/bin/sh +# +# An example hook script to prepare the commit log message. +# Called by "git commit" with the name of the file that has the +# commit message, followed by the description of the commit +# message's source. The hook's purpose is to edit the commit +# message file. If the hook fails with a non-zero status, +# the commit is aborted. +# +# To enable this hook, rename this file to "prepare-commit-msg". + +# This hook includes three examples. The first one removes the +# "# Please enter the commit message..." help message. +# +# The second includes the output of "git diff --name-status -r" +# into the message, just before the "git status" output. It is +# commented because it doesn't cope with --amend or with squashed +# commits. +# +# The third example adds a Signed-off-by line to the message, that can +# still be edited. This is rarely a good idea. + +COMMIT_MSG_FILE=$1 +COMMIT_SOURCE=$2 +SHA1=$3 + +/usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE" + +# case "$COMMIT_SOURCE,$SHA1" in +# ,|template,) +# /usr/bin/perl -i.bak -pe ' +# print "\n" . `git diff --cached --name-status -r` +# if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;; +# *) ;; +# esac + +# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE" +# if test -z "$COMMIT_SOURCE" +# then +# /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE" +# fi diff --git a/integrations/gitea-repositories-meta/user5/repo4.git/hooks/update.sample b/integrations/gitea-repositories-meta/user5/repo4.git/hooks/update.sample new file mode 100755 index 0000000000..5014c4b31c --- /dev/null +++ b/integrations/gitea-repositories-meta/user5/repo4.git/hooks/update.sample @@ -0,0 +1,128 @@ +#!/bin/sh +# +# An example hook script to block unannotated tags from entering. +# Called by "git receive-pack" with arguments: refname sha1-old sha1-new +# +# To enable this hook, rename this file to "update". +# +# Config +# ------ +# hooks.allowunannotated +# This boolean sets whether unannotated tags will be allowed into the +# repository. By default they won't be. +# hooks.allowdeletetag +# This boolean sets whether deleting tags will be allowed in the +# repository. By default they won't be. +# hooks.allowmodifytag +# This boolean sets whether a tag may be modified after creation. By default +# it won't be. +# hooks.allowdeletebranch +# This boolean sets whether deleting branches will be allowed in the +# repository. By default they won't be. +# hooks.denycreatebranch +# This boolean sets whether remotely creating branches will be denied +# in the repository. By default this is allowed. +# + +# --- Command line +refname="$1" +oldrev="$2" +newrev="$3" + +# --- Safety check +if [ -z "$GIT_DIR" ]; then + echo "Don't run this script from the command line." >&2 + echo " (if you want, you could supply GIT_DIR then run" >&2 + echo " $0 <ref> <oldrev> <newrev>)" >&2 + exit 1 +fi + +if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then + echo "usage: $0 <ref> <oldrev> <newrev>" >&2 + exit 1 +fi + +# --- Config +allowunannotated=$(git config --type=bool hooks.allowunannotated) +allowdeletebranch=$(git config --type=bool hooks.allowdeletebranch) +denycreatebranch=$(git config --type=bool hooks.denycreatebranch) +allowdeletetag=$(git config --type=bool hooks.allowdeletetag) +allowmodifytag=$(git config --type=bool hooks.allowmodifytag) + +# check for no description +projectdesc=$(sed -e '1q' "$GIT_DIR/description") +case "$projectdesc" in +"Unnamed repository"* | "") + echo "*** Project description file hasn't been set" >&2 + exit 1 + ;; +esac + +# --- Check types +# if $newrev is 0000...0000, it's a commit to delete a ref. +zero="0000000000000000000000000000000000000000" +if [ "$newrev" = "$zero" ]; then + newrev_type=delete +else + newrev_type=$(git cat-file -t $newrev) +fi + +case "$refname","$newrev_type" in + refs/tags/*,commit) + # un-annotated tag + short_refname=${refname##refs/tags/} + if [ "$allowunannotated" != "true" ]; then + echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2 + echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 + exit 1 + fi + ;; + refs/tags/*,delete) + # delete tag + if [ "$allowdeletetag" != "true" ]; then + echo "*** Deleting a tag is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/tags/*,tag) + # annotated tag + if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1 + then + echo "*** Tag '$refname' already exists." >&2 + echo "*** Modifying a tag is not allowed in this repository." >&2 + exit 1 + fi + ;; + refs/heads/*,commit) + # branch + if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then + echo "*** Creating a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/heads/*,delete) + # delete branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/remotes/*,commit) + # tracking branch + ;; + refs/remotes/*,delete) + # delete tracking branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a tracking branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + *) + # Anything else (is there anything else?) + echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2 + exit 1 + ;; +esac + +# --- Finished +exit 0 diff --git a/integrations/gitea-repositories-meta/user5/repo4.git/info/exclude b/integrations/gitea-repositories-meta/user5/repo4.git/info/exclude new file mode 100644 index 0000000000..a5196d1be8 --- /dev/null +++ b/integrations/gitea-repositories-meta/user5/repo4.git/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/integrations/gitea-repositories-meta/user5/repo4.git/objects/16/dfebd1ed3905d78d7e061e945fc9c34afe4e81 b/integrations/gitea-repositories-meta/user5/repo4.git/objects/16/dfebd1ed3905d78d7e061e945fc9c34afe4e81 Binary files differnew file mode 100644 index 0000000000..76d765ea90 --- /dev/null +++ b/integrations/gitea-repositories-meta/user5/repo4.git/objects/16/dfebd1ed3905d78d7e061e945fc9c34afe4e81 diff --git a/integrations/gitea-repositories-meta/user5/repo4.git/objects/c1/202ad022ae7d3a6d2474dc76d5a0c8e87cdc0f b/integrations/gitea-repositories-meta/user5/repo4.git/objects/c1/202ad022ae7d3a6d2474dc76d5a0c8e87cdc0f Binary files differnew file mode 100644 index 0000000000..f63d6019b8 --- /dev/null +++ b/integrations/gitea-repositories-meta/user5/repo4.git/objects/c1/202ad022ae7d3a6d2474dc76d5a0c8e87cdc0f diff --git a/integrations/gitea-repositories-meta/user5/repo4.git/objects/c7/cd3cd144e6d23c9d6f3d07e52b2c1a956e0338 b/integrations/gitea-repositories-meta/user5/repo4.git/objects/c7/cd3cd144e6d23c9d6f3d07e52b2c1a956e0338 Binary files differnew file mode 100644 index 0000000000..c8d7c54d58 --- /dev/null +++ b/integrations/gitea-repositories-meta/user5/repo4.git/objects/c7/cd3cd144e6d23c9d6f3d07e52b2c1a956e0338 diff --git a/integrations/gitea-repositories-meta/user5/repo4.git/refs/heads/master b/integrations/gitea-repositories-meta/user5/repo4.git/refs/heads/master new file mode 100644 index 0000000000..5fd26e37da --- /dev/null +++ b/integrations/gitea-repositories-meta/user5/repo4.git/refs/heads/master @@ -0,0 +1 @@ +c7cd3cd144e6d23c9d6f3d07e52b2c1a956e0338 diff --git a/integrations/links_test.go b/integrations/links_test.go index 2db07e8814..2c674c104a 100644 --- a/integrations/links_test.go +++ b/integrations/links_test.go @@ -33,6 +33,9 @@ func TestLinksNoLogin(t *testing.T) { "/user/forgot_password", "/api/swagger", "/api/v1/swagger", + "/user2/repo1", + "/user2/repo1/projects", + "/user2/repo1/projects/1", } for _, link := range links { @@ -58,6 +61,20 @@ func TestRedirectsNoLogin(t *testing.T) { } } +func TestNoLoginNotExist(t *testing.T) { + defer prepareTestEnv(t)() + + var links = []string{ + "/user5/repo4/projects", + "/user5/repo4/projects/3", + } + + for _, link := range links { + req := NewRequest(t, "GET", link) + MakeRequest(t, req, http.StatusNotFound) + } +} + func testLinksAsUser(userName string, t *testing.T) { var links = []string{ "/explore/repos", diff --git a/models/error.go b/models/error.go index e9343cbe7c..13391e5d87 100644 --- a/models/error.go +++ b/models/error.go @@ -1586,6 +1586,44 @@ func (err ErrLabelNotExist) Error() string { return fmt.Sprintf("label does not exist [label_id: %d]", err.LabelID) } +// __________ __ __ +// \______ \_______ ____ |__| ____ _____/ |_ ______ +// | ___/\_ __ \/ _ \ | |/ __ \_/ ___\ __\/ ___/ +// | | | | \( <_> ) | \ ___/\ \___| | \___ \ +// |____| |__| \____/\__| |\___ >\___ >__| /____ > +// \______| \/ \/ \/ + +// ErrProjectNotExist represents a "ProjectNotExist" kind of error. +type ErrProjectNotExist struct { + ID int64 + RepoID int64 +} + +// IsErrProjectNotExist checks if an error is a ErrProjectNotExist +func IsErrProjectNotExist(err error) bool { + _, ok := err.(ErrProjectNotExist) + return ok +} + +func (err ErrProjectNotExist) Error() string { + return fmt.Sprintf("projects does not exist [id: %d]", err.ID) +} + +// ErrProjectBoardNotExist represents a "ProjectBoardNotExist" kind of error. +type ErrProjectBoardNotExist struct { + BoardID int64 +} + +// IsErrProjectBoardNotExist checks if an error is a ErrProjectBoardNotExist +func IsErrProjectBoardNotExist(err error) bool { + _, ok := err.(ErrProjectBoardNotExist) + return ok +} + +func (err ErrProjectBoardNotExist) Error() string { + return fmt.Sprintf("project board does not exist [id: %d]", err.BoardID) +} + // _____ .__.__ __ // / \ |__| | ____ _______/ |_ ____ ____ ____ // / \ / \| | | _/ __ \ / ___/\ __\/ _ \ / \_/ __ \ diff --git a/models/fixtures/project.yml b/models/fixtures/project.yml new file mode 100644 index 0000000000..3d42597c5e --- /dev/null +++ b/models/fixtures/project.yml @@ -0,0 +1,26 @@ +- + id: 1 + title: First project + repo_id: 1 + is_closed: false + creator_id: 2 + board_type: 1 + type: 2 + +- + id: 2 + title: second project + repo_id: 3 + is_closed: false + creator_id: 3 + board_type: 1 + type: 2 + +- + id: 3 + title: project on repo with disabled project + repo_id: 4 + is_closed: true + creator_id: 5 + board_type: 1 + type: 2 diff --git a/models/fixtures/project_board.yml b/models/fixtures/project_board.yml new file mode 100644 index 0000000000..9e06e8c239 --- /dev/null +++ b/models/fixtures/project_board.yml @@ -0,0 +1,23 @@ +- + id: 1 + project_id: 1 + title: To Do + creator_id: 2 + created_unix: 1588117528 + updated_unix: 1588117528 + +- + id: 2 + project_id: 1 + title: In Progress + creator_id: 2 + created_unix: 1588117528 + updated_unix: 1588117528 + +- + id: 3 + project_id: 1 + title: Done + creator_id: 2 + created_unix: 1588117528 + updated_unix: 1588117528 diff --git a/models/fixtures/project_issue.yml b/models/fixtures/project_issue.yml new file mode 100644 index 0000000000..b1af05908a --- /dev/null +++ b/models/fixtures/project_issue.yml @@ -0,0 +1,23 @@ +- + id: 1 + issue_id: 1 + project_id: 1 + project_board_id: 1 + +- + id: 2 + issue_id: 2 + project_id: 1 + project_board_id: 0 # no board assigned + +- + id: 3 + issue_id: 3 + project_id: 1 + project_board_id: 2 + +- + id: 4 + issue_id: 5 + project_id: 1 + project_board_id: 3 diff --git a/models/fixtures/repo_unit.yml b/models/fixtures/repo_unit.yml index 35b9b92b79..726abf9af9 100644 --- a/models/fixtures/repo_unit.yml +++ b/models/fixtures/repo_unit.yml @@ -514,3 +514,21 @@ type: 3 config: "{\"IgnoreWhitespaceConflicts\":false,\"AllowMerge\":true,\"AllowRebase\":true,\"AllowRebaseMerge\":true,\"AllowSquash\":true}" created_unix: 946684810 + +- + id: 75 + repo_id: 1 + type: 8 + created_unix: 946684810 + +- + id: 76 + repo_id: 2 + type: 8 + created_unix: 946684810 + +- + id: 77 + repo_id: 3 + type: 8 + created_unix: 946684810 diff --git a/models/fixtures/repository.yml b/models/fixtures/repository.yml index 3b86dd0f81..a44e480270 100644 --- a/models/fixtures/repository.yml +++ b/models/fixtures/repository.yml @@ -13,6 +13,8 @@ num_milestones: 3 num_closed_milestones: 1 num_watches: 4 + num_projects: 1 + num_closed_projects: 0 status: 0 - @@ -42,6 +44,8 @@ num_pulls: 0 num_closed_pulls: 0 num_watches: 0 + num_projects: 1 + num_closed_projects: 0 status: 0 - @@ -56,6 +60,8 @@ num_pulls: 0 num_closed_pulls: 0 num_stars: 1 + num_projects: 0 + num_closed_projects: 1 status: 0 - diff --git a/models/issue.go b/models/issue.go index 1a4de26b3a..07d7fc9956 100644 --- a/models/issue.go +++ b/models/issue.go @@ -41,6 +41,7 @@ type Issue struct { Labels []*Label `xorm:"-"` MilestoneID int64 `xorm:"INDEX"` Milestone *Milestone `xorm:"-"` + Project *Project `xorm:"-"` Priority int AssigneeID int64 `xorm:"-"` Assignee *User `xorm:"-"` @@ -274,6 +275,10 @@ func (issue *Issue) loadAttributes(e Engine) (err error) { return } + if err = issue.loadProject(e); err != nil { + return + } + if err = issue.loadAssignees(e); err != nil { return } @@ -1062,6 +1067,8 @@ type IssuesOptions struct { PosterID int64 MentionedID int64 MilestoneIDs []int64 + ProjectID int64 + ProjectBoardID int64 IsClosed util.OptionalBool IsPull util.OptionalBool LabelIDs []int64 @@ -1147,6 +1154,19 @@ func (opts *IssuesOptions) setupSession(sess *xorm.Session) { sess.In("issue.milestone_id", opts.MilestoneIDs) } + if opts.ProjectID > 0 { + sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id"). + And("project_issue.project_id=?", opts.ProjectID) + } + + if opts.ProjectBoardID != 0 { + if opts.ProjectBoardID > 0 { + sess.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.Eq{"project_board_id": opts.ProjectBoardID})) + } else { + sess.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.Eq{"project_board_id": 0})) + } + } + switch opts.IsPull { case util.OptionalBoolTrue: sess.And("issue.is_pull=?", true) @@ -1953,6 +1973,11 @@ func deleteIssuesByRepoID(sess Engine, repoID int64) (attachmentPaths []string, return } + if _, err = sess.In("issue_id", deleteCond). + Delete(&ProjectIssue{}); err != nil { + return + } + var attachments []*Attachment if err = sess.In("issue_id", deleteCond). Find(&attachments); err != nil { diff --git a/models/issue_comment.go b/models/issue_comment.go index 94fca493e0..726ed7472b 100644 --- a/models/issue_comment.go +++ b/models/issue_comment.go @@ -97,6 +97,10 @@ const ( CommentTypeMergePull // push to PR head branch CommentTypePullPush + // Project changed + CommentTypeProject + // Project board changed + CommentTypeProjectBoard ) // CommentTag defines comment tag type @@ -122,6 +126,10 @@ type Comment struct { Issue *Issue `xorm:"-"` LabelID int64 Label *Label `xorm:"-"` + OldProjectID int64 + ProjectID int64 + OldProject *Project `xorm:"-"` + Project *Project `xorm:"-"` OldMilestoneID int64 MilestoneID int64 OldMilestone *Milestone `xorm:"-"` @@ -389,6 +397,32 @@ func (c *Comment) LoadLabel() error { return nil } +// LoadProject if comment.Type is CommentTypeProject, then load project. +func (c *Comment) LoadProject() error { + + if c.OldProjectID > 0 { + var oldProject Project + has, err := x.ID(c.OldProjectID).Get(&oldProject) + if err != nil { + return err + } else if has { + c.OldProject = &oldProject + } + } + + if c.ProjectID > 0 { + var project Project + has, err := x.ID(c.ProjectID).Get(&project) + if err != nil { + return err + } else if has { + c.Project = &project + } + } + + return nil +} + // LoadMilestone if comment.Type is CommentTypeMilestone, then load milestone func (c *Comment) LoadMilestone() error { if c.OldMilestoneID > 0 { @@ -647,6 +681,8 @@ func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err LabelID: LabelID, OldMilestoneID: opts.OldMilestoneID, MilestoneID: opts.MilestoneID, + OldProjectID: opts.OldProjectID, + ProjectID: opts.ProjectID, RemovedAssignee: opts.RemovedAssignee, AssigneeID: opts.AssigneeID, CommitID: opts.CommitID, @@ -810,6 +846,8 @@ type CreateCommentOptions struct { DependentIssueID int64 OldMilestoneID int64 MilestoneID int64 + OldProjectID int64 + ProjectID int64 AssigneeID int64 RemovedAssignee bool OldTitle string diff --git a/models/issue_milestone.go b/models/issue_milestone.go index 824b939a56..f4fba84ec0 100644 --- a/models/issue_milestone.go +++ b/models/issue_milestone.go @@ -183,6 +183,33 @@ func updateMilestoneCompleteness(e Engine, milestoneID int64) error { return err } +// ChangeMilestoneStatusByRepoIDAndID changes a milestone open/closed status if the milestone ID is in the repo. +func ChangeMilestoneStatusByRepoIDAndID(repoID, milestoneID int64, isClosed bool) error { + sess := x.NewSession() + defer sess.Close() + if err := sess.Begin(); err != nil { + return err + } + + m := &Milestone{ + ID: milestoneID, + RepoID: repoID, + } + + has, err := sess.ID(milestoneID).Where("repo_id = ?", repoID).Get(m) + if err != nil { + return err + } else if !has { + return ErrMilestoneNotExist{ID: milestoneID, RepoID: repoID} + } + + if err := changeMilestoneStatus(sess, m, isClosed); err != nil { + return err + } + + return sess.Commit() +} + // ChangeMilestoneStatus changes the milestone open/closed status. func ChangeMilestoneStatus(m *Milestone, isClosed bool) (err error) { sess := x.NewSession() @@ -191,20 +218,27 @@ func ChangeMilestoneStatus(m *Milestone, isClosed bool) (err error) { return err } + if err := changeMilestoneStatus(sess, m, isClosed); err != nil { + return err + } + + return sess.Commit() +} + +func changeMilestoneStatus(e Engine, m *Milestone, isClosed bool) error { m.IsClosed = isClosed if isClosed { m.ClosedDateUnix = timeutil.TimeStampNow() } - if _, err := sess.ID(m.ID).Cols("is_closed", "closed_date_unix").Update(m); err != nil { + count, err := e.ID(m.ID).Where("repo_id = ? AND is_closed = ?", m.RepoID, !isClosed).Cols("is_closed", "closed_date_unix").Update(m) + if err != nil { return err } - - if err := updateRepoMilestoneNum(sess, m.RepoID); err != nil { - return err + if count < 1 { + return nil } - - return sess.Commit() + return updateRepoMilestoneNum(e, m.RepoID) } func changeMilestoneAssign(e *xorm.Session, doer *User, issue *Issue, oldMilestoneID int64) error { diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 834ac3bd68..b9fdfbfe7e 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -224,6 +224,8 @@ var migrations = []Migration{ NewMigration("update Matrix Webhook http method to 'PUT'", updateMatrixWebhookHTTPMethod), // v145 -> v146 NewMigration("Increase Language field to 50 in LanguageStats", increaseLanguageField), + // v146 -> v147 + NewMigration("Add projects info to repository table", addProjectsInfo), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v146.go b/models/migrations/v146.go new file mode 100644 index 0000000000..847bcf567c --- /dev/null +++ b/models/migrations/v146.go @@ -0,0 +1,85 @@ +// Copyright 2020 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 ( + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" +) + +func addProjectsInfo(x *xorm.Engine) error { + + // Create new tables + type ( + ProjectType uint8 + ProjectBoardType uint8 + ) + + type Project struct { + ID int64 `xorm:"pk autoincr"` + Title string `xorm:"INDEX NOT NULL"` + Description string `xorm:"TEXT"` + RepoID int64 `xorm:"INDEX"` + CreatorID int64 `xorm:"NOT NULL"` + IsClosed bool `xorm:"INDEX"` + + BoardType ProjectBoardType + Type ProjectType + + ClosedDateUnix timeutil.TimeStamp + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + } + + if err := x.Sync2(new(Project)); err != nil { + return err + } + + type Comment struct { + OldProjectID int64 + ProjectID int64 + } + + if err := x.Sync2(new(Comment)); err != nil { + return err + } + + type Repository struct { + ID int64 + NumProjects int `xorm:"NOT NULL DEFAULT 0"` + NumClosedProjects int `xorm:"NOT NULL DEFAULT 0"` + } + + if err := x.Sync2(new(Repository)); err != nil { + return err + } + + // ProjectIssue saves relation from issue to a project + type ProjectIssue struct { + ID int64 `xorm:"pk autoincr"` + IssueID int64 `xorm:"INDEX"` + ProjectID int64 `xorm:"INDEX"` + ProjectBoardID int64 `xorm:"INDEX"` + } + + if err := x.Sync2(new(ProjectIssue)); err != nil { + return err + } + + type ProjectBoard struct { + ID int64 `xorm:"pk autoincr"` + Title string + Default bool `xorm:"NOT NULL DEFAULT false"` + + ProjectID int64 `xorm:"INDEX NOT NULL"` + CreatorID int64 `xorm:"NOT NULL"` + + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + } + + return x.Sync2(new(ProjectBoard)) +} diff --git a/models/models.go b/models/models.go index d0703be300..e0dd3ed2a4 100644 --- a/models/models.go +++ b/models/models.go @@ -45,6 +45,7 @@ type Engine interface { SQL(interface{}, ...interface{}) *xorm.Session Where(interface{}, ...interface{}) *xorm.Session Asc(colNames ...string) *xorm.Session + Desc(colNames ...string) *xorm.Session Limit(limit int, start ...int) *xorm.Session SumInt(bean interface{}, columnName string) (res int64, err error) } @@ -125,6 +126,9 @@ func init() { new(Task), new(LanguageStat), new(EmailHash), + new(Project), + new(ProjectBoard), + new(ProjectIssue), ) gonicNames := []string{"SSL", "UID"} diff --git a/models/project.go b/models/project.go new file mode 100644 index 0000000000..e032da351d --- /dev/null +++ b/models/project.go @@ -0,0 +1,307 @@ +// Copyright 2020 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 models + +import ( + "errors" + "fmt" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" +) + +type ( + // ProjectsConfig is used to identify the type of board that is being created + ProjectsConfig struct { + BoardType ProjectBoardType + Translation string + } + + // ProjectType is used to identify the type of project in question and ownership + ProjectType uint8 +) + +const ( + // ProjectTypeIndividual is a type of project board that is owned by an individual + ProjectTypeIndividual ProjectType = iota + 1 + + // ProjectTypeRepository is a project that is tied to a repository + ProjectTypeRepository + + // ProjectTypeOrganization is a project that is tied to an organisation + ProjectTypeOrganization +) + +// Project represents a project board +type Project struct { + ID int64 `xorm:"pk autoincr"` + Title string `xorm:"INDEX NOT NULL"` + Description string `xorm:"TEXT"` + RepoID int64 `xorm:"INDEX"` + CreatorID int64 `xorm:"NOT NULL"` + IsClosed bool `xorm:"INDEX"` + BoardType ProjectBoardType + Type ProjectType + + RenderedContent string `xorm:"-"` + + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + ClosedDateUnix timeutil.TimeStamp +} + +// GetProjectsConfig retrieves the types of configurations projects could have +func GetProjectsConfig() []ProjectsConfig { + return []ProjectsConfig{ + {ProjectBoardTypeNone, "repo.projects.type.none"}, + {ProjectBoardTypeBasicKanban, "repo.projects.type.basic_kanban"}, + {ProjectBoardTypeBugTriage, "repo.projects.type.bug_triage"}, + } +} + +// IsProjectTypeValid checks if a project type is valid +func IsProjectTypeValid(p ProjectType) bool { + switch p { + case ProjectTypeRepository: + return true + default: + return false + } +} + +// ProjectSearchOptions are options for GetProjects +type ProjectSearchOptions struct { + RepoID int64 + Page int + IsClosed util.OptionalBool + SortType string + Type ProjectType +} + +// GetProjects returns a list of all projects that have been created in the repository +func GetProjects(opts ProjectSearchOptions) ([]*Project, int64, error) { + return getProjects(x, opts) +} + +func getProjects(e Engine, opts ProjectSearchOptions) ([]*Project, int64, error) { + + projects := make([]*Project, 0, setting.UI.IssuePagingNum) + + var cond builder.Cond = builder.Eq{"repo_id": opts.RepoID} + switch opts.IsClosed { + case util.OptionalBoolTrue: + cond = cond.And(builder.Eq{"is_closed": true}) + case util.OptionalBoolFalse: + cond = cond.And(builder.Eq{"is_closed": false}) + } + + if opts.Type > 0 { + cond = cond.And(builder.Eq{"type": opts.Type}) + } + + count, err := e.Where(cond).Count(new(Project)) + if err != nil { + return nil, 0, fmt.Errorf("Count: %v", err) + } + + e = e.Where(cond) + + if opts.Page > 0 { + e = e.Limit(setting.UI.IssuePagingNum, (opts.Page-1)*setting.UI.IssuePagingNum) + } + + switch opts.SortType { + case "oldest": + e.Desc("created_unix") + case "recentupdate": + e.Desc("updated_unix") + case "leastupdate": + e.Asc("updated_unix") + default: + e.Asc("created_unix") + } + + return projects, count, e.Find(&projects) +} + +// NewProject creates a new Project +func NewProject(p *Project) error { + if !IsProjectBoardTypeValid(p.BoardType) { + p.BoardType = ProjectBoardTypeNone + } + + if !IsProjectTypeValid(p.Type) { + return errors.New("project type is not valid") + } + + sess := x.NewSession() + defer sess.Close() + + if err := sess.Begin(); err != nil { + return err + } + + if _, err := sess.Insert(p); err != nil { + return err + } + + if _, err := sess.Exec("UPDATE `repository` SET num_projects = num_projects + 1 WHERE id = ?", p.RepoID); err != nil { + return err + } + + if err := createBoardsForProjectsType(sess, p); err != nil { + return err + } + + return sess.Commit() +} + +// GetProjectByID returns the projects in a repository +func GetProjectByID(id int64) (*Project, error) { + return getProjectByID(x, id) +} + +func getProjectByID(e Engine, id int64) (*Project, error) { + p := new(Project) + + has, err := e.ID(id).Get(p) + if err != nil { + return nil, err + } else if !has { + return nil, ErrProjectNotExist{ID: id} + } + + return p, nil +} + +// UpdateProject updates project properties +func UpdateProject(p *Project) error { + return updateProject(x, p) +} + +func updateProject(e Engine, p *Project) error { + _, err := e.ID(p.ID).Cols( + "title", + "description", + ).Update(p) + return err +} + +func updateRepositoryProjectCount(e Engine, repoID int64) error { + if _, err := e.Exec(builder.Update( + builder.Eq{ + "`num_projects`": builder.Select("count(*)").From("`project`"). + Where(builder.Eq{"`project`.`repo_id`": repoID}. + And(builder.Eq{"`project`.`type`": ProjectTypeRepository})), + }).From("`repository`").Where(builder.Eq{"id": repoID})); err != nil { + return err + } + + if _, err := e.Exec(builder.Update( + builder.Eq{ + "`num_closed_projects`": builder.Select("count(*)").From("`project`"). + Where(builder.Eq{"`project`.`repo_id`": repoID}. + And(builder.Eq{"`project`.`type`": ProjectTypeRepository}). + And(builder.Eq{"`project`.`is_closed`": true})), + }).From("`repository`").Where(builder.Eq{"id": repoID})); err != nil { + return err + } + return nil +} + +// ChangeProjectStatusByRepoIDAndID toggles a project between opened and closed +func ChangeProjectStatusByRepoIDAndID(repoID, projectID int64, isClosed bool) error { + sess := x.NewSession() + defer sess.Close() + if err := sess.Begin(); err != nil { + return err + } + + p := new(Project) + + has, err := sess.ID(projectID).Where("repo_id = ?", repoID).Get(p) + if err != nil { + return err + } else if !has { + return ErrProjectNotExist{ID: projectID, RepoID: repoID} + } + + if err := changeProjectStatus(sess, p, isClosed); err != nil { + return err + } + + return sess.Commit() +} + +// ChangeProjectStatus toggle a project between opened and closed +func ChangeProjectStatus(p *Project, isClosed bool) error { + sess := x.NewSession() + defer sess.Close() + if err := sess.Begin(); err != nil { + return err + } + + if err := changeProjectStatus(sess, p, isClosed); err != nil { + return err + } + + return sess.Commit() +} + +func changeProjectStatus(e Engine, p *Project, isClosed bool) error { + p.IsClosed = isClosed + p.ClosedDateUnix = timeutil.TimeStampNow() + count, err := e.ID(p.ID).Where("repo_id = ? AND is_closed = ?", p.RepoID, !isClosed).Cols("is_closed", "closed_date_unix").Update(p) + if err != nil { + return err + } + if count < 1 { + return nil + } + + return updateRepositoryProjectCount(e, p.RepoID) +} + +// DeleteProjectByID deletes a project from a repository. +func DeleteProjectByID(id int64) error { + sess := x.NewSession() + defer sess.Close() + if err := sess.Begin(); err != nil { + return err + } + + if err := deleteProjectByID(sess, id); err != nil { + return err + } + + return sess.Commit() +} + +func deleteProjectByID(e Engine, id int64) error { + p, err := getProjectByID(e, id) + if err != nil { + if IsErrProjectNotExist(err) { + return nil + } + return err + } + + if err := deleteProjectIssuesByProjectID(e, id); err != nil { + return err + } + + if err := deleteProjectBoardByProjectID(e, id); err != nil { + return err + } + + if _, err = e.ID(p.ID).Delete(new(Project)); err != nil { + return err + } + + return updateRepositoryProjectCount(e, p.RepoID) +} diff --git a/models/project_board.go b/models/project_board.go new file mode 100644 index 0000000000..260fc8304b --- /dev/null +++ b/models/project_board.go @@ -0,0 +1,220 @@ +// Copyright 2020 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 models + +import ( + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" +) + +type ( + // ProjectBoardType is used to represent a project board type + ProjectBoardType uint8 + + // ProjectBoardList is a list of all project boards in a repository + ProjectBoardList []*ProjectBoard +) + +const ( + // ProjectBoardTypeNone is a project board type that has no predefined columns + ProjectBoardTypeNone ProjectBoardType = iota + + // ProjectBoardTypeBasicKanban is a project board type that has basic predefined columns + ProjectBoardTypeBasicKanban + + // ProjectBoardTypeBugTriage is a project board type that has predefined columns suited to hunting down bugs + ProjectBoardTypeBugTriage +) + +// ProjectBoard is used to represent boards on a project +type ProjectBoard struct { + ID int64 `xorm:"pk autoincr"` + Title string + Default bool `xorm:"NOT NULL DEFAULT false"` // issues not assigned to a specific board will be assigned to this board + + ProjectID int64 `xorm:"INDEX NOT NULL"` + CreatorID int64 `xorm:"NOT NULL"` + + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + + Issues []*Issue `xorm:"-"` +} + +// IsProjectBoardTypeValid checks if the project board type is valid +func IsProjectBoardTypeValid(p ProjectBoardType) bool { + switch p { + case ProjectBoardTypeNone, ProjectBoardTypeBasicKanban, ProjectBoardTypeBugTriage: + return true + default: + return false + } +} + +func createBoardsForProjectsType(sess *xorm.Session, project *Project) error { + + var items []string + + switch project.BoardType { + + case ProjectBoardTypeBugTriage: + items = setting.Project.ProjectBoardBugTriageType + + case ProjectBoardTypeBasicKanban: + items = setting.Project.ProjectBoardBasicKanbanType + + case ProjectBoardTypeNone: + fallthrough + default: + return nil + } + + if len(items) == 0 { + return nil + } + + var boards = make([]ProjectBoard, 0, len(items)) + + for _, v := range items { + boards = append(boards, ProjectBoard{ + CreatedUnix: timeutil.TimeStampNow(), + CreatorID: project.CreatorID, + Title: v, + ProjectID: project.ID, + }) + } + + _, err := sess.Insert(boards) + return err +} + +// NewProjectBoard adds a new project board to a given project +func NewProjectBoard(board *ProjectBoard) error { + _, err := x.Insert(board) + return err +} + +// DeleteProjectBoardByID removes all issues references to the project board. +func DeleteProjectBoardByID(boardID int64) error { + sess := x.NewSession() + defer sess.Close() + if err := sess.Begin(); err != nil { + return err + } + + if err := deleteProjectBoardByID(sess, boardID); err != nil { + return err + } + + return sess.Commit() +} + +func deleteProjectBoardByID(e Engine, boardID int64) error { + board, err := getProjectBoard(e, boardID) + if err != nil { + if IsErrProjectBoardNotExist(err) { + return nil + } + + return err + } + + if err = board.removeIssues(e); err != nil { + return err + } + + if _, err := e.ID(board.ID).Delete(board); err != nil { + return err + } + return nil +} + +func deleteProjectBoardByProjectID(e Engine, projectID int64) error { + _, err := e.Where("project_id=?", projectID).Delete(&ProjectBoard{}) + return err +} + +// GetProjectBoard fetches the current board of a project +func GetProjectBoard(boardID int64) (*ProjectBoard, error) { + return getProjectBoard(x, boardID) +} + +func getProjectBoard(e Engine, boardID int64) (*ProjectBoard, error) { + board := new(ProjectBoard) + + has, err := e.ID(boardID).Get(board) + if err != nil { + return nil, err + } else if !has { + return nil, ErrProjectBoardNotExist{BoardID: boardID} + } + + return board, nil +} + +// UpdateProjectBoard updates the title of a project board +func UpdateProjectBoard(board *ProjectBoard) error { + return updateProjectBoard(x, board) +} + +func updateProjectBoard(e Engine, board *ProjectBoard) error { + _, err := e.ID(board.ID).Cols( + "title", + "default", + ).Update(board) + return err +} + +// GetProjectBoards fetches all boards related to a project +func GetProjectBoards(projectID int64) ([]*ProjectBoard, error) { + + var boards = make([]*ProjectBoard, 0, 5) + + sess := x.Where("project_id=?", projectID) + return boards, sess.Find(&boards) +} + +// GetUncategorizedBoard represents a board for issues not assigned to one +func GetUncategorizedBoard(projectID int64) (*ProjectBoard, error) { + return &ProjectBoard{ + ProjectID: projectID, + Title: "Uncategorized", + Default: true, + }, nil +} + +// LoadIssues load issues assigned to this board +func (b *ProjectBoard) LoadIssues() (IssueList, error) { + var boardID int64 + if !b.Default { + boardID = b.ID + + } else { + // Issues without ProjectBoardID + boardID = -1 + } + issues, err := Issues(&IssuesOptions{ + ProjectBoardID: boardID, + ProjectID: b.ProjectID, + }) + b.Issues = issues + return issues, err +} + +// LoadIssues load issues assigned to the boards +func (bs ProjectBoardList) LoadIssues() (IssueList, error) { + issues := make(IssueList, 0, len(bs)*10) + for i := range bs { + il, err := bs[i].LoadIssues() + if err != nil { + return nil, err + } + bs[i].Issues = il + issues = append(issues, il...) + } + return issues, nil +} diff --git a/models/project_issue.go b/models/project_issue.go new file mode 100644 index 0000000000..c41bfe5158 --- /dev/null +++ b/models/project_issue.go @@ -0,0 +1,210 @@ +// Copyright 2020 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 models + +import ( + "fmt" + + "xorm.io/xorm" +) + +// ProjectIssue saves relation from issue to a project +type ProjectIssue struct { + ID int64 `xorm:"pk autoincr"` + IssueID int64 `xorm:"INDEX"` + ProjectID int64 `xorm:"INDEX"` + + // If 0, then it has not been added to a specific board in the project + ProjectBoardID int64 `xorm:"INDEX"` +} + +func deleteProjectIssuesByProjectID(e Engine, projectID int64) error { + _, err := e.Where("project_id=?", projectID).Delete(&ProjectIssue{}) + return err +} + +// ___ +// |_ _|___ ___ _ _ ___ +// | |/ __/ __| | | |/ _ \ +// | |\__ \__ \ |_| | __/ +// |___|___/___/\__,_|\___| + +// LoadProject load the project the issue was assigned to +func (i *Issue) LoadProject() (err error) { + return i.loadProject(x) +} + +func (i *Issue) loadProject(e Engine) (err error) { + if i.Project == nil { + var p Project + if _, err = e.Table("project"). + Join("INNER", "project_issue", "project.id=project_issue.project_id"). + Where("project_issue.issue_id = ?", i.ID). + Get(&p); err != nil { + return err + } + i.Project = &p + } + return +} + +// ProjectID return project id if issue was assigned to one +func (i *Issue) ProjectID() int64 { + return i.projectID(x) +} + +func (i *Issue) projectID(e Engine) int64 { + var ip ProjectIssue + has, err := e.Where("issue_id=?", i.ID).Get(&ip) + if err != nil || !has { + return 0 + } + return ip.ProjectID +} + +// ProjectBoardID return project board id if issue was assigned to one +func (i *Issue) ProjectBoardID() int64 { + return i.projectBoardID(x) +} + +func (i *Issue) projectBoardID(e Engine) int64 { + var ip ProjectIssue + has, err := e.Where("issue_id=?", i.ID).Get(&ip) + if err != nil || !has { + return 0 + } + return ip.ProjectBoardID +} + +// ____ _ _ +// | _ \ _ __ ___ (_) ___ ___| |_ +// | |_) | '__/ _ \| |/ _ \/ __| __| +// | __/| | | (_) | | __/ (__| |_ +// |_| |_| \___// |\___|\___|\__| +// |__/ + +// NumIssues return counter of all issues assigned to a project +func (p *Project) NumIssues() int { + c, err := x.Table("project_issue"). + Where("project_id=?", p.ID). + GroupBy("issue_id"). + Cols("issue_id"). + Count() + if err != nil { + return 0 + } + return int(c) +} + +// NumClosedIssues return counter of closed issues assigned to a project +func (p *Project) NumClosedIssues() int { + c, err := x.Table("project_issue"). + Join("INNER", "issue", "project_issue.issue_id=issue.id"). + Where("project_issue.project_id=? AND issue.is_closed=?", p.ID, true). + Cols("issue_id"). + Count() + if err != nil { + return 0 + } + return int(c) +} + +// NumOpenIssues return counter of open issues assigned to a project +func (p *Project) NumOpenIssues() int { + c, err := x.Table("project_issue"). + Join("INNER", "issue", "project_issue.issue_id=issue.id"). + Where("project_issue.project_id=? AND issue.is_closed=?", p.ID, false).Count("issue.id") + if err != nil { + return 0 + } + return int(c) +} + +// ChangeProjectAssign changes the project associated with an issue +func ChangeProjectAssign(issue *Issue, doer *User, newProjectID int64) error { + + sess := x.NewSession() + defer sess.Close() + if err := sess.Begin(); err != nil { + return err + } + + if err := addUpdateIssueProject(sess, issue, doer, newProjectID); err != nil { + return err + } + + return sess.Commit() +} + +func addUpdateIssueProject(e *xorm.Session, issue *Issue, doer *User, newProjectID int64) error { + + oldProjectID := issue.projectID(e) + + if _, err := e.Where("project_issue.issue_id=?", issue.ID).Delete(&ProjectIssue{}); err != nil { + return err + } + + if err := issue.loadRepo(e); err != nil { + return err + } + + if oldProjectID > 0 || newProjectID > 0 { + if _, err := createComment(e, &CreateCommentOptions{ + Type: CommentTypeProject, + Doer: doer, + Repo: issue.Repo, + Issue: issue, + OldProjectID: oldProjectID, + ProjectID: newProjectID, + }); err != nil { + return err + } + } + + _, err := e.Insert(&ProjectIssue{ + IssueID: issue.ID, + ProjectID: newProjectID, + }) + return err +} + +// ____ _ _ ____ _ +// | _ \ _ __ ___ (_) ___ ___| |_| __ ) ___ __ _ _ __ __| | +// | |_) | '__/ _ \| |/ _ \/ __| __| _ \ / _ \ / _` | '__/ _` | +// | __/| | | (_) | | __/ (__| |_| |_) | (_) | (_| | | | (_| | +// |_| |_| \___// |\___|\___|\__|____/ \___/ \__,_|_| \__,_| +// |__/ + +// MoveIssueAcrossProjectBoards move a card from one board to another +func MoveIssueAcrossProjectBoards(issue *Issue, board *ProjectBoard) error { + + sess := x.NewSession() + defer sess.Close() + if err := sess.Begin(); err != nil { + return err + } + + var pis ProjectIssue + has, err := sess.Where("issue_id=?", issue.ID).Get(&pis) + if err != nil { + return err + } + + if !has { + return fmt.Errorf("issue has to be added to a project first") + } + + pis.ProjectBoardID = board.ID + if _, err := sess.ID(pis.ID).Cols("project_board_id").Update(&pis); err != nil { + return err + } + + return sess.Commit() +} + +func (pb *ProjectBoard) removeIssues(e Engine) error { + _, err := e.Exec("UPDATE `project_issue` SET project_board_id = 0 WHERE project_board_id = ? ", pb.ID) + return err +} diff --git a/models/project_test.go b/models/project_test.go new file mode 100644 index 0000000000..49c46f9184 --- /dev/null +++ b/models/project_test.go @@ -0,0 +1,82 @@ +// Copyright 2020 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 models + +import ( + "testing" + + "code.gitea.io/gitea/modules/timeutil" + + "github.com/stretchr/testify/assert" +) + +func TestIsProjectTypeValid(t *testing.T) { + const UnknownType ProjectType = 15 + + var cases = []struct { + typ ProjectType + valid bool + }{ + {ProjectTypeIndividual, false}, + {ProjectTypeRepository, true}, + {ProjectTypeOrganization, false}, + {UnknownType, false}, + } + + for _, v := range cases { + assert.Equal(t, v.valid, IsProjectTypeValid(v.typ)) + } +} + +func TestGetProjects(t *testing.T) { + assert.NoError(t, PrepareTestDatabase()) + + projects, _, err := GetProjects(ProjectSearchOptions{RepoID: 1}) + assert.NoError(t, err) + + // 1 value for this repo exists in the fixtures + assert.Len(t, projects, 1) + + projects, _, err = GetProjects(ProjectSearchOptions{RepoID: 3}) + assert.NoError(t, err) + + // 1 value for this repo exists in the fixtures + assert.Len(t, projects, 1) +} + +func TestProject(t *testing.T) { + assert.NoError(t, PrepareTestDatabase()) + + project := &Project{ + Type: ProjectTypeRepository, + BoardType: ProjectBoardTypeBasicKanban, + Title: "New Project", + RepoID: 1, + CreatedUnix: timeutil.TimeStampNow(), + CreatorID: 2, + } + + assert.NoError(t, NewProject(project)) + + _, err := GetProjectByID(project.ID) + assert.NoError(t, err) + + // Update project + project.Title = "Updated title" + assert.NoError(t, UpdateProject(project)) + + projectFromDB, err := GetProjectByID(project.ID) + assert.NoError(t, err) + + assert.Equal(t, project.Title, projectFromDB.Title) + + assert.NoError(t, ChangeProjectStatus(project, true)) + + // Retrieve from DB afresh to check if it is truly closed + projectFromDB, err = GetProjectByID(project.ID) + assert.NoError(t, err) + + assert.True(t, projectFromDB.IsClosed) +} diff --git a/models/repo.go b/models/repo.go index 9f7ce8af1e..146868d876 100644 --- a/models/repo.go +++ b/models/repo.go @@ -168,6 +168,9 @@ type Repository struct { NumMilestones int `xorm:"NOT NULL DEFAULT 0"` NumClosedMilestones int `xorm:"NOT NULL DEFAULT 0"` NumOpenMilestones int `xorm:"-"` + NumProjects int `xorm:"NOT NULL DEFAULT 0"` + NumClosedProjects int `xorm:"NOT NULL DEFAULT 0"` + NumOpenProjects int `xorm:"-"` IsPrivate bool `xorm:"INDEX"` IsEmpty bool `xorm:"INDEX"` @@ -237,6 +240,7 @@ func (repo *Repository) AfterLoad() { repo.NumOpenIssues = repo.NumIssues - repo.NumClosedIssues repo.NumOpenPulls = repo.NumPulls - repo.NumClosedPulls repo.NumOpenMilestones = repo.NumMilestones - repo.NumClosedMilestones + repo.NumOpenProjects = repo.NumProjects - repo.NumClosedProjects } // MustOwner always returns a valid *User object to avoid @@ -307,6 +311,8 @@ func (repo *Repository) innerAPIFormat(e Engine, mode AccessMode, isParent bool) parent = repo.BaseRepo.innerAPIFormat(e, mode, true) } } + + //check enabled/disabled units hasIssues := false var externalTracker *api.ExternalTracker var internalTracker *api.InternalTracker @@ -353,6 +359,10 @@ func (repo *Repository) innerAPIFormat(e Engine, mode AccessMode, isParent bool) allowRebaseMerge = config.AllowRebaseMerge allowSquash = config.AllowSquash } + hasProjects := false + if _, err := repo.getUnit(e, UnitTypeProjects); err == nil { + hasProjects = true + } repo.mustOwner(e) @@ -390,6 +400,7 @@ func (repo *Repository) innerAPIFormat(e Engine, mode AccessMode, isParent bool) ExternalTracker: externalTracker, InternalTracker: internalTracker, HasWiki: hasWiki, + HasProjects: hasProjects, ExternalWiki: externalWiki, HasPullRequests: hasPullRequests, IgnoreWhitespaceConflicts: ignoreWhitespaceConflicts, @@ -1641,6 +1652,18 @@ func DeleteRepository(doer *User, uid, repoID int64) error { } } + projects, _, err := getProjects(sess, ProjectSearchOptions{ + RepoID: repoID, + }) + if err != nil { + return fmt.Errorf("get projects: %v", err) + } + for i := range projects { + if err := deleteProjectByID(sess, projects[i].ID); err != nil { + return fmt.Errorf("delete project [%d]: %v", projects[i].ID, err) + } + } + // FIXME: Remove repository files should be executed after transaction succeed. repoPath := repo.RepoPath() removeAllWithNotice(sess, "Delete repository files", repoPath) diff --git a/models/repo_unit.go b/models/repo_unit.go index 42ce8f6c8d..d4c74515f7 100644 --- a/models/repo_unit.go +++ b/models/repo_unit.go @@ -118,7 +118,7 @@ func (r *RepoUnit) BeforeSet(colName string, val xorm.Cell) { switch colName { case "type": switch UnitType(Cell2Int64(val)) { - case UnitTypeCode, UnitTypeReleases, UnitTypeWiki: + case UnitTypeCode, UnitTypeReleases, UnitTypeWiki, UnitTypeProjects: r.Config = new(UnitConfig) case UnitTypeExternalWiki: r.Config = new(ExternalWikiConfig) diff --git a/models/unit.go b/models/unit.go index bd2e6b13a6..939deba574 100644 --- a/models/unit.go +++ b/models/unit.go @@ -24,6 +24,7 @@ const ( UnitTypeWiki // 5 Wiki UnitTypeExternalWiki // 6 ExternalWiki UnitTypeExternalTracker // 7 ExternalTracker + UnitTypeProjects // 8 Kanban board ) // Value returns integer value for unit type @@ -47,6 +48,8 @@ func (u UnitType) String() string { return "UnitTypeExternalWiki" case UnitTypeExternalTracker: return "UnitTypeExternalTracker" + case UnitTypeProjects: + return "UnitTypeProjects" } return fmt.Sprintf("Unknown UnitType %d", u) } @@ -68,6 +71,7 @@ var ( UnitTypeWiki, UnitTypeExternalWiki, UnitTypeExternalTracker, + UnitTypeProjects, } // DefaultRepoUnits contains the default unit types @@ -77,6 +81,7 @@ var ( UnitTypePullRequests, UnitTypeReleases, UnitTypeWiki, + UnitTypeProjects, } // NotAllowedDefaultRepoUnits contains units that can't be default @@ -242,6 +247,14 @@ var ( 4, } + UnitProjects = Unit{ + UnitTypeProjects, + "repo.projects", + "/projects", + "repo.projects.desc", + 5, + } + // Units contains all the units Units = map[UnitType]Unit{ UnitTypeCode: UnitCode, @@ -251,6 +264,7 @@ var ( UnitTypeReleases: UnitReleases, UnitTypeWiki: UnitWiki, UnitTypeExternalWiki: UnitExternalWiki, + UnitTypeProjects: UnitProjects, } ) diff --git a/modules/auth/repo_form.go b/modules/auth/repo_form.go index 6c3421e4f7..696d3b9a53 100644 --- a/modules/auth/repo_form.go +++ b/modules/auth/repo_form.go @@ -128,6 +128,7 @@ type RepoSettingForm struct { ExternalTrackerURL string TrackerURLFormat string TrackerIssueStyle string + EnableProjects bool EnablePulls bool PullsIgnoreWhitespace bool PullsAllowMerge bool @@ -364,6 +365,7 @@ type CreateIssueForm struct { AssigneeIDs string `form:"assignee_ids"` Ref string `form:"ref"` MilestoneID int64 + ProjectID int64 AssigneeID int64 Content string Files []string @@ -422,6 +424,35 @@ func (i IssueLockForm) HasValidReason() bool { return false } +// __________ __ __ +// \______ \_______ ____ |__| ____ _____/ |_ ______ +// | ___/\_ __ \/ _ \ | |/ __ \_/ ___\ __\/ ___/ +// | | | | \( <_> ) | \ ___/\ \___| | \___ \ +// |____| |__| \____/\__| |\___ >\___ >__| /____ > +// \______| \/ \/ \/ + +// CreateProjectForm form for creating a project +type CreateProjectForm struct { + Title string `binding:"Required;MaxSize(100)"` + Content string + BoardType models.ProjectBoardType +} + +// UserCreateProjectForm is a from for creating an individual or organization +// form. +type UserCreateProjectForm struct { + Title string `binding:"Required;MaxSize(100)"` + Content string + BoardType models.ProjectBoardType + UID int64 `binding:"Required"` +} + +// EditProjectBoardTitleForm is a form for editing the title of a project's +// board +type EditProjectBoardTitleForm struct { + Title string `binding:"Required;MaxSize(100)"` +} + // _____ .__.__ __ // / \ |__| | ____ _______/ |_ ____ ____ ____ // / \ / \| | | _/ __ \ / ___/\ __\/ _ \ / \_/ __ \ diff --git a/modules/context/repo.go b/modules/context/repo.go index 5ebed0eb7e..4aac0c05aa 100644 --- a/modules/context/repo.go +++ b/modules/context/repo.go @@ -818,5 +818,6 @@ func UnitTypes() macaron.Handler { ctx.Data["UnitTypeWiki"] = models.UnitTypeWiki ctx.Data["UnitTypeExternalWiki"] = models.UnitTypeExternalWiki ctx.Data["UnitTypeExternalTracker"] = models.UnitTypeExternalTracker + ctx.Data["UnitTypeProjects"] = models.UnitTypeProjects } } diff --git a/modules/setting/project.go b/modules/setting/project.go new file mode 100644 index 0000000000..56505b0ca4 --- /dev/null +++ b/modules/setting/project.go @@ -0,0 +1,24 @@ +// Copyright 2020 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 setting + +import "code.gitea.io/gitea/modules/log" + +// Project settings +var ( + Project = struct { + ProjectBoardBasicKanbanType []string + ProjectBoardBugTriageType []string + }{ + ProjectBoardBasicKanbanType: []string{"To Do", "In Progress", "Done"}, + ProjectBoardBugTriageType: []string{"Needs Triage", "High Priority", "Low Priority", "Closed"}, + } +) + +func newProject() { + if err := Cfg.Section("project").MapTo(&Project); err != nil { + log.Fatal("Failed to map Project settings: %v", err) + } +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index d77df2d75f..33854a2300 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -1124,4 +1124,5 @@ func NewServices() { newIndexerService() newTaskService() NewQueueService() + newProject() } diff --git a/modules/structs/repo.go b/modules/structs/repo.go index 2ff1a1ec26..217a6f74ad 100644 --- a/modules/structs/repo.go +++ b/modules/structs/repo.go @@ -82,6 +82,7 @@ type Repository struct { HasWiki bool `json:"has_wiki"` ExternalWiki *ExternalWiki `json:"external_wiki,omitempty"` HasPullRequests bool `json:"has_pull_requests"` + HasProjects bool `json:"has_projects"` IgnoreWhitespaceConflicts bool `json:"ignore_whitespace_conflicts"` AllowMerge bool `json:"allow_merge_commits"` AllowRebase bool `json:"allow_rebase"` @@ -147,6 +148,8 @@ type EditRepoOption struct { DefaultBranch *string `json:"default_branch,omitempty"` // either `true` to allow pull requests, or `false` to prevent pull request. HasPullRequests *bool `json:"has_pull_requests,omitempty"` + // either `true` to enable project unit, or `false` to disable them. + HasProjects *bool `json:"has_projects,omitempty"` // either `true` to ignore whitespace for conflicts, or `false` to not ignore whitespace. `has_pull_requests` must be `true`. IgnoreWhitespaceConflicts *bool `json:"ignore_whitespace_conflicts,omitempty"` // either `true` to allow merging pull requests with a merge commit, or `false` to prevent merging pull requests with merge commits. `has_pull_requests` must be `true`. diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index e429486df5..a59470428f 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -52,6 +52,8 @@ new_migrate = New Migration new_mirror = New Mirror new_fork = New Repository Fork new_org = New Organization +new_project = New Project +new_project_board = New Project board manage_org = Manage Organizations admin_panel = Site Administration account_settings = Account Settings @@ -389,6 +391,7 @@ repositories = Repositories activity = Public Activity followers = Followers starred = Starred Repositories +projects = Projects following = Following follow = Follow unfollow = Unfollow @@ -757,6 +760,7 @@ branches = Branches tags = Tags issues = Issues pulls = Pull Requests +project_board = Projects labels = Labels org_labels_desc = Organization level labels that can be used with <strong>all repositories</strong> under this organization org_labels_desc_manage = manage @@ -858,9 +862,39 @@ commits.gpg_key_id = GPG Key ID ext_issues = Ext. Issues ext_issues.desc = Link to an external issue tracker. +projects.create = Create Project +projects.title = Title +projects.new = New project +projects.new_subheader = Coordinate, track, and update your work in one place, so projects stay transparent and on schedule. +projects.desc = Description +projects.create_success = The project '%s' has been created. +projects.deletion = Delete Project +projects.deletion_desc = Deleting a project removes it from all related issues. Continue? +projects.deletion_success = The project has been deleted. +projects.edit = Edit Projects +projects.edit_subheader = Projects organize issues and track progress. +projects.modify = Update Project +projects.edit_success = Project '%s' has been updated. +projects.type.none = "None" +projects.type.basic_kanban = "Basic Kanban" +projects.type.bug_triage = "Bug Triage" +projects.template.desc = "Project template" +projects.template.desc_helper = "Select a project template to get started" +projects.type.uncategorized = Uncategorized +projects.board.edit = "Edit board" +projects.board.edit_title = "New Board Name" +projects.board.new_title = "New Board Name" +projects.board.new_submit = "Submit" +projects.board.new = "New Board" +projects.board.delete = "Delete Board" +projects.board.deletion_desc = "Deleting a project board moves all related issues to 'Uncategorized'. Continue?" +projects.open = Open +projects.close = Close + issues.desc = Organize bug reports, tasks and milestones. issues.filter_assignees = Filter Assignee issues.filter_milestones = Filter Milestone +issues.filter_projects = Filter Project issues.filter_labels = Filter Label issues.filter_reviewers = Filter Reviewer issues.new = New Issue @@ -869,6 +903,12 @@ issues.new.labels = Labels issues.new.add_labels_title = Apply labels issues.new.no_label = No Label issues.new.clear_labels = Clear labels +issues.new.projects = Projects +issues.new.add_project_title = Set Project +issues.new.clear_projects = Clear projects +issues.new.no_projects = No project +issues.new.open_projects = Open Projects +issues.new.closed_projects = Closed Projects issues.new.no_items = No items issues.new.milestone = Milestone issues.new.add_milestone_title = Set milestone @@ -896,9 +936,13 @@ issues.label_templates.fail_to_load_file = Failed to load label template file '% issues.add_label_at = added the <div class="ui label" style="color: %s\; background-color: %s">%s</div> label %s issues.remove_label_at = removed the <div class="ui label" style="color: %s\; background-color: %s">%s</div> label %s issues.add_milestone_at = `added this to the <b>%s</b> milestone %s` +issues.add_project_at = `added this to the <b>%s</b> project %s` issues.change_milestone_at = `modified the milestone from <b>%s</b> to <b>%s</b> %s` +issues.change_project_at = `modified the project from <b>%s</b> to <b>%s</b> %s` issues.remove_milestone_at = `removed this from the <b>%s</b> milestone %s` +issues.remove_project_at = `removed this from the <b>%s</b> project %s` issues.deleted_milestone = `(deleted)` +issues.deleted_project = `(deleted)` issues.self_assign_at = `self-assigned this %s` issues.add_assignee_at = `was assigned by <b>%s</b> %s` issues.remove_assignee_at = `was unassigned by <b>%s</b> %s` @@ -1374,6 +1418,7 @@ settings.pulls.allow_merge_commits = Enable Commit Merging settings.pulls.allow_rebase_merge = Enable Rebasing to Merge Commits settings.pulls.allow_rebase_merge_commit = Enable Rebasing with explicit merge commits (--no-ff) settings.pulls.allow_squash_commits = Enable Squashing to Merge Commits +settings.projects_desc = Enable Repository Projects settings.admin_settings = Administrator Settings settings.admin_enable_health_check = Enable Repository Health Checks (git fsck) settings.admin_enable_close_issues_via_commit_in_any_branch = Close an issue via a commit made in a non default branch diff --git a/package-lock.json b/package-lock.json index 1b2d6f0a1f..d35d615616 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12438,6 +12438,11 @@ "is-plain-obj": "^1.0.0" } }, + "sortablejs": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.10.2.tgz", + "integrity": "sha512-YkPGufevysvfwn5rfdlGyrGjt7/CRHwvRPogD/lC+TnvcN29jDpCifKP+rBqf+LRldfXSTh+0CGLcSg0VIxq3A==" + }, "source-list-map": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", diff --git a/package.json b/package.json index bff9e5ca14..983ec0edcf 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "postcss-loader": "3.0.0", "postcss-preset-env": "6.7.0", "raw-loader": "4.0.1", + "sortablejs": "1.10.2", "swagger-ui": "3.31.1", "terser-webpack-plugin": "4.1.0", "tributejs": "5.1.3", diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index 93898cd7ef..5ebc7f251b 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -719,6 +719,17 @@ func updateRepoUnits(ctx *context.APIContext, opts api.EditRepoOption) error { } } + if opts.HasProjects != nil && !models.UnitTypeProjects.UnitGlobalDisabled() { + if *opts.HasProjects { + units = append(units, models.RepoUnit{ + RepoID: repo.ID, + Type: models.UnitTypeProjects, + }) + } else { + deleteUnitTypes = append(deleteUnitTypes, models.UnitTypeProjects) + } + } + if err := models.UpdateRepositoryUnits(repo, units, deleteUnitTypes); err != nil { ctx.Error(http.StatusInternalServerError, "UpdateRepositoryUnits", err) return err diff --git a/routers/repo/issue.go b/routers/repo/issue.go index 1f8f484132..2eabd6ab6c 100644 --- a/routers/repo/issue.go +++ b/routers/repo/issue.go @@ -104,7 +104,7 @@ func MustAllowPulls(ctx *context.Context) { } } -func issues(ctx *context.Context, milestoneID int64, isPullOption util.OptionalBool) { +func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption util.OptionalBool) { var err error viewType := ctx.Query("type") sortType := ctx.Query("sort") @@ -215,6 +215,7 @@ func issues(ctx *context.Context, milestoneID int64, isPullOption util.OptionalB PosterID: posterID, MentionedID: mentionedID, MilestoneIDs: mileIDs, + ProjectID: projectID, IsClosed: util.OptionalBoolOf(isShowClosed), IsPull: isPullOption, LabelIDs: labelIDs, @@ -357,7 +358,7 @@ func Issues(ctx *context.Context) { ctx.Data["PageIsIssueList"] = true } - issues(ctx, ctx.QueryInt64("milestone"), util.OptionalBoolOf(isPullList)) + issues(ctx, ctx.QueryInt64("milestone"), ctx.QueryInt64("project"), util.OptionalBoolOf(isPullList)) var err error // Get milestones @@ -402,6 +403,33 @@ func RetrieveRepoMilestonesAndAssignees(ctx *context.Context, repo *models.Repos } } +func retrieveProjects(ctx *context.Context, repo *models.Repository) { + + var err error + + ctx.Data["OpenProjects"], _, err = models.GetProjects(models.ProjectSearchOptions{ + RepoID: repo.ID, + Page: -1, + IsClosed: util.OptionalBoolFalse, + Type: models.ProjectTypeRepository, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } + + ctx.Data["ClosedProjects"], _, err = models.GetProjects(models.ProjectSearchOptions{ + RepoID: repo.ID, + Page: -1, + IsClosed: util.OptionalBoolTrue, + Type: models.ProjectTypeRepository, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } +} + // RetrieveRepoReviewers find all reviewers of a repository func RetrieveRepoReviewers(ctx *context.Context, repo *models.Repository, issuePosterID int64) { var err error @@ -439,6 +467,11 @@ func RetrieveRepoMetas(ctx *context.Context, repo *models.Repository, isPull boo return nil } + retrieveProjects(ctx, repo) + if ctx.Written() { + return nil + } + brs, err := ctx.Repo.GitRepo.GetBranches() if err != nil { ctx.ServerError("GetBranches", err) @@ -502,6 +535,7 @@ func NewIssue(ctx *context.Context) { ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes body := ctx.Query("body") ctx.Data["BodyQuery"] = body + ctx.Data["IsProjectsEnabled"] = ctx.Repo.CanRead(models.UnitTypeProjects) milestoneID := ctx.QueryInt64("milestone") if milestoneID > 0 { @@ -514,6 +548,20 @@ func NewIssue(ctx *context.Context) { } } + projectID := ctx.QueryInt64("project") + if projectID > 0 { + project, err := models.GetProjectByID(projectID) + if err != nil { + log.Error("GetProjectByID: %d: %v", projectID, err) + } else if project.RepoID != ctx.Repo.Repository.ID { + log.Error("GetProjectByID: %d: %v", projectID, fmt.Errorf("project[%d] not in repo [%d]", project.ID, ctx.Repo.Repository.ID)) + } else { + ctx.Data["project_id"] = projectID + ctx.Data["Project"] = project + } + + } + setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates) renderAttachmentSettings(ctx) @@ -528,7 +576,7 @@ func NewIssue(ctx *context.Context) { } // ValidateRepoMetas check and returns repository's meta informations -func ValidateRepoMetas(ctx *context.Context, form auth.CreateIssueForm, isPull bool) ([]int64, []int64, int64) { +func ValidateRepoMetas(ctx *context.Context, form auth.CreateIssueForm, isPull bool) ([]int64, []int64, int64, int64) { var ( repo = ctx.Repo.Repository err error @@ -536,7 +584,7 @@ func ValidateRepoMetas(ctx *context.Context, form auth.CreateIssueForm, isPull b labels := RetrieveRepoMetas(ctx, ctx.Repo.Repository, isPull) if ctx.Written() { - return nil, nil, 0 + return nil, nil, 0, 0 } var labelIDs []int64 @@ -545,7 +593,7 @@ func ValidateRepoMetas(ctx *context.Context, form auth.CreateIssueForm, isPull b if len(form.LabelIDs) > 0 { labelIDs, err = base.StringsToInt64s(strings.Split(form.LabelIDs, ",")) if err != nil { - return nil, nil, 0 + return nil, nil, 0, 0 } labelIDMark := base.Int64sToMap(labelIDs) @@ -567,17 +615,32 @@ func ValidateRepoMetas(ctx *context.Context, form auth.CreateIssueForm, isPull b ctx.Data["Milestone"], err = repo.GetMilestoneByID(milestoneID) if err != nil { ctx.ServerError("GetMilestoneByID", err) - return nil, nil, 0 + return nil, nil, 0, 0 } ctx.Data["milestone_id"] = milestoneID } + if form.ProjectID > 0 { + p, err := models.GetProjectByID(form.ProjectID) + if err != nil { + ctx.ServerError("GetProjectByID", err) + return nil, nil, 0, 0 + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return nil, nil, 0, 0 + } + + ctx.Data["Project"] = p + ctx.Data["project_id"] = form.ProjectID + } + // Check assignees var assigneeIDs []int64 if len(form.AssigneeIDs) > 0 { assigneeIDs, err = base.StringsToInt64s(strings.Split(form.AssigneeIDs, ",")) if err != nil { - return nil, nil, 0 + return nil, nil, 0, 0 } // Check if the passed assignees actually exists and is assignable @@ -585,17 +648,18 @@ func ValidateRepoMetas(ctx *context.Context, form auth.CreateIssueForm, isPull b assignee, err := models.GetUserByID(aID) if err != nil { ctx.ServerError("GetUserByID", err) - return nil, nil, 0 + return nil, nil, 0, 0 } valid, err := models.CanBeAssigned(assignee, repo, isPull) if err != nil { - ctx.ServerError("canBeAssigned", err) - return nil, nil, 0 + ctx.ServerError("CanBeAssigned", err) + return nil, nil, 0, 0 } + if !valid { ctx.ServerError("canBeAssigned", models.ErrUserDoesNotHaveAccessToRepo{UserID: aID, RepoName: repo.Name}) - return nil, nil, 0 + return nil, nil, 0, 0 } } } @@ -605,7 +669,7 @@ func ValidateRepoMetas(ctx *context.Context, form auth.CreateIssueForm, isPull b assigneeIDs = append(assigneeIDs, form.AssigneeID) } - return labelIDs, assigneeIDs, milestoneID + return labelIDs, assigneeIDs, milestoneID, form.ProjectID } // NewIssuePost response for creating new issue @@ -623,7 +687,7 @@ func NewIssuePost(ctx *context.Context, form auth.CreateIssueForm) { attachments []string ) - labelIDs, assigneeIDs, milestoneID := ValidateRepoMetas(ctx, form, false) + labelIDs, assigneeIDs, milestoneID, projectID := ValidateRepoMetas(ctx, form, false) if ctx.Written() { return } @@ -661,6 +725,13 @@ func NewIssuePost(ctx *context.Context, form auth.CreateIssueForm) { return } + if projectID > 0 { + if err := models.ChangeProjectAssign(issue, ctx.User, projectID); err != nil { + ctx.ServerError("ChangeProjectAssign", err) + return + } + } + log.Trace("Issue created: %d/%d", repo.ID, issue.ID) ctx.Redirect(ctx.Repo.RepoLink + "/issues/" + com.ToStr(issue.Index)) } @@ -758,6 +829,8 @@ func ViewIssue(ctx *context.Context) { ctx.Data["RequireHighlightJS"] = true ctx.Data["RequireTribute"] = true ctx.Data["RequireSimpleMDE"] = true + ctx.Data["IsProjectsEnabled"] = ctx.Repo.CanRead(models.UnitTypeProjects) + renderAttachmentSettings(ctx) if err = issue.LoadAttributes(); err != nil { @@ -839,6 +912,8 @@ func ViewIssue(ctx *context.Context) { // Check milestone and assignee. if ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { RetrieveRepoMilestonesAndAssignees(ctx, repo) + retrieveProjects(ctx, repo) + if ctx.Written() { return } @@ -977,6 +1052,26 @@ func ViewIssue(ctx *context.Context) { if comment.MilestoneID > 0 && comment.Milestone == nil { comment.Milestone = ghostMilestone } + } else if comment.Type == models.CommentTypeProject { + + if err = comment.LoadProject(); err != nil { + ctx.ServerError("LoadProject", err) + return + } + + ghostProject := &models.Project{ + ID: -1, + Title: ctx.Tr("repo.issues.deleted_project"), + } + + if comment.OldProjectID > 0 && comment.OldProject == nil { + comment.OldProject = ghostProject + } + + if comment.ProjectID > 0 && comment.Project == nil { + comment.Project = ghostProject + } + } else if comment.Type == models.CommentTypeAssignees || comment.Type == models.CommentTypeReviewRequest { if err = comment.LoadAssigneeUser(); err != nil { ctx.ServerError("LoadAssigneeUser", err) @@ -1149,6 +1244,7 @@ func ViewIssue(ctx *context.Context) { ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login?redirect_to=" + ctx.Data["Link"].(string) ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.User.ID) ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) + ctx.Data["HasProjectsWritePermission"] = ctx.Repo.CanWrite(models.UnitTypeProjects) ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.User.IsAdmin) ctx.Data["LockReasons"] = setting.Repository.Issue.LockReasons ctx.Data["RefEndName"] = git.RefEndName(issue.Ref) diff --git a/routers/repo/milestone.go b/routers/repo/milestone.go index 0bd7344878..f48c5de12e 100644 --- a/routers/repo/milestone.go +++ b/routers/repo/milestone.go @@ -207,39 +207,28 @@ func EditMilestonePost(ctx *context.Context, form auth.CreateMilestoneForm) { ctx.Redirect(ctx.Repo.RepoLink + "/milestones") } -// ChangeMilestonStatus response for change a milestone's status -func ChangeMilestonStatus(ctx *context.Context) { - m, err := models.GetMilestoneByRepoID(ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")) - if err != nil { - if models.IsErrMilestoneNotExist(err) { - ctx.NotFound("", err) - } else { - ctx.ServerError("GetMilestoneByRepoID", err) - } - return - } - +// ChangeMilestoneStatus response for change a milestone's status +func ChangeMilestoneStatus(ctx *context.Context) { + toClose := false switch ctx.Params(":action") { case "open": - if m.IsClosed { - if err = models.ChangeMilestoneStatus(m, false); err != nil { - ctx.ServerError("ChangeMilestoneStatus", err) - return - } - } - ctx.Redirect(ctx.Repo.RepoLink + "/milestones?state=open") + toClose = false case "close": - if !m.IsClosed { - m.ClosedDateUnix = timeutil.TimeStampNow() - if err = models.ChangeMilestoneStatus(m, true); err != nil { - ctx.ServerError("ChangeMilestoneStatus", err) - return - } - } - ctx.Redirect(ctx.Repo.RepoLink + "/milestones?state=closed") + toClose = true default: ctx.Redirect(ctx.Repo.RepoLink + "/milestones") } + id := ctx.ParamsInt64(":id") + + if err := models.ChangeMilestoneStatusByRepoIDAndID(ctx.Repo.Repository.ID, id, toClose); err != nil { + if models.IsErrMilestoneNotExist(err) { + ctx.NotFound("", err) + } else { + ctx.ServerError("ChangeMilestoneStatusByIDAndRepoID", err) + } + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/milestones?state=" + ctx.Params(":action")) } // DeleteMilestone delete a milestone @@ -274,7 +263,7 @@ func MilestoneIssuesAndPulls(ctx *context.Context) { ctx.Data["Title"] = milestone.Name ctx.Data["Milestone"] = milestone - issues(ctx, milestoneID, util.OptionalBoolNone) + issues(ctx, milestoneID, 0, util.OptionalBoolNone) ctx.Data["CanWriteIssues"] = ctx.Repo.CanWriteIssuesOrPulls(false) ctx.Data["CanWritePulls"] = ctx.Repo.CanWriteIssuesOrPulls(true) diff --git a/routers/repo/projects.go b/routers/repo/projects.go new file mode 100644 index 0000000000..daa94a308d --- /dev/null +++ b/routers/repo/projects.go @@ -0,0 +1,591 @@ +// Copyright 2020 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 repo + +import ( + "fmt" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/auth" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" +) + +const ( + tplProjects base.TplName = "repo/projects/list" + tplProjectsNew base.TplName = "repo/projects/new" + tplProjectsView base.TplName = "repo/projects/view" + tplGenericProjectsNew base.TplName = "user/project" +) + +// MustEnableProjects check if projects are enabled in settings +func MustEnableProjects(ctx *context.Context) { + if models.UnitTypeProjects.UnitGlobalDisabled() { + ctx.NotFound("EnableKanbanBoard", nil) + return + } + + if ctx.Repo.Repository != nil { + if !ctx.Repo.CanRead(models.UnitTypeProjects) { + ctx.NotFound("MustEnableProjects", nil) + return + } + } +} + +// Projects renders the home page of projects +func Projects(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.project_board") + + sortType := ctx.QueryTrim("sort") + + isShowClosed := strings.ToLower(ctx.QueryTrim("state")) == "closed" + repo := ctx.Repo.Repository + page := ctx.QueryInt("page") + if page <= 1 { + page = 1 + } + + ctx.Data["OpenCount"] = repo.NumOpenProjects + ctx.Data["ClosedCount"] = repo.NumClosedProjects + + var total int + if !isShowClosed { + total = repo.NumOpenProjects + } else { + total = repo.NumClosedProjects + } + + projects, count, err := models.GetProjects(models.ProjectSearchOptions{ + RepoID: repo.ID, + Page: page, + IsClosed: util.OptionalBoolOf(isShowClosed), + SortType: sortType, + Type: models.ProjectTypeRepository, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } + + for i := range projects { + projects[i].RenderedContent = string(markdown.Render([]byte(projects[i].Description), ctx.Repo.RepoLink, ctx.Repo.Repository.ComposeMetas())) + } + + ctx.Data["Projects"] = projects + + if isShowClosed { + ctx.Data["State"] = "closed" + } else { + ctx.Data["State"] = "open" + } + + numPages := 0 + if count > 0 { + numPages = int((int(count) - 1) / setting.UI.IssuePagingNum) + } + + pager := context.NewPagination(total, setting.UI.IssuePagingNum, page, numPages) + pager.AddParam(ctx, "state", "State") + ctx.Data["Page"] = pager + + ctx.Data["IsShowClosed"] = isShowClosed + ctx.Data["IsProjectsPage"] = true + ctx.Data["SortType"] = sortType + + ctx.HTML(200, tplProjects) +} + +// NewProject render creating a project page +func NewProject(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.projects.new") + ctx.Data["ProjectTypes"] = models.GetProjectsConfig() + + ctx.HTML(200, tplProjectsNew) +} + +// NewRepoProjectPost creates a new project +func NewRepoProjectPost(ctx *context.Context, form auth.CreateProjectForm) { + + ctx.Data["Title"] = ctx.Tr("repo.projects.new") + + if ctx.HasError() { + ctx.HTML(200, tplProjectsNew) + return + } + + if err := models.NewProject(&models.Project{ + RepoID: ctx.Repo.Repository.ID, + Title: form.Title, + Description: form.Content, + CreatorID: ctx.User.ID, + BoardType: form.BoardType, + Type: models.ProjectTypeRepository, + }); err != nil { + ctx.ServerError("NewProject", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title)) + ctx.Redirect(ctx.Repo.RepoLink + "/projects") +} + +// ChangeProjectStatus updates the status of a project between "open" and "close" +func ChangeProjectStatus(ctx *context.Context) { + toClose := false + switch ctx.Params(":action") { + case "open": + toClose = false + case "close": + toClose = true + default: + ctx.Redirect(ctx.Repo.RepoLink + "/projects") + } + id := ctx.ParamsInt64(":id") + + if err := models.ChangeProjectStatusByRepoIDAndID(ctx.Repo.Repository.ID, id, toClose); err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", err) + } else { + ctx.ServerError("ChangeProjectStatusByIDAndRepoID", err) + } + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/projects?state=" + ctx.Params(":action")) +} + +// DeleteProject delete a project +func DeleteProject(ctx *context.Context) { + p, err := models.GetProjectByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + if err := models.DeleteProjectByID(p.ID); err != nil { + ctx.Flash.Error("DeleteProjectByID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success")) + } + + ctx.JSON(200, map[string]interface{}{ + "redirect": ctx.Repo.RepoLink + "/projects", + }) +} + +// EditProject allows a project to be edited +func EditProject(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.projects.edit") + ctx.Data["PageIsProjects"] = true + ctx.Data["PageIsEditProjects"] = true + + p, err := models.GetProjectByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + ctx.Data["title"] = p.Title + ctx.Data["content"] = p.Description + + ctx.HTML(200, tplProjectsNew) +} + +// EditProjectPost response for editing a project +func EditProjectPost(ctx *context.Context, form auth.CreateProjectForm) { + ctx.Data["Title"] = ctx.Tr("repo.projects.edit") + ctx.Data["PageIsProjects"] = true + ctx.Data["PageIsEditProjects"] = true + + if ctx.HasError() { + ctx.HTML(200, tplMilestoneNew) + return + } + + p, err := models.GetProjectByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + p.Title = form.Title + p.Description = form.Content + if err = models.UpdateProject(p); err != nil { + ctx.ServerError("UpdateProjects", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.projects.edit_success", p.Title)) + ctx.Redirect(ctx.Repo.RepoLink + "/projects") +} + +// ViewProject renders the project board for a project +func ViewProject(ctx *context.Context) { + + project, err := models.GetProjectByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if project.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + uncategorizedBoard, err := models.GetUncategorizedBoard(project.ID) + uncategorizedBoard.Title = ctx.Tr("repo.projects.type.uncategorized") + if err != nil { + ctx.ServerError("GetUncategorizedBoard", err) + return + } + + boards, err := models.GetProjectBoards(project.ID) + if err != nil { + ctx.ServerError("GetProjectBoards", err) + return + } + + allBoards := models.ProjectBoardList{uncategorizedBoard} + allBoards = append(allBoards, boards...) + + if ctx.Data["Issues"], err = allBoards.LoadIssues(); err != nil { + ctx.ServerError("LoadIssuesOfBoards", err) + return + } + + ctx.Data["Project"] = project + ctx.Data["Boards"] = allBoards + ctx.Data["PageIsProjects"] = true + ctx.Data["RequiresDraggable"] = true + + ctx.HTML(200, tplProjectsView) +} + +// UpdateIssueProject change an issue's project +func UpdateIssueProject(ctx *context.Context) { + issues := getActionIssues(ctx) + if ctx.Written() { + return + } + + projectID := ctx.QueryInt64("id") + for _, issue := range issues { + oldProjectID := issue.ProjectID() + if oldProjectID == projectID { + continue + } + + if err := models.ChangeProjectAssign(issue, ctx.User, projectID); err != nil { + ctx.ServerError("ChangeProjectAssign", err) + return + } + } + + ctx.JSON(200, map[string]interface{}{ + "ok": true, + }) +} + +// DeleteProjectBoard allows for the deletion of a project board +func DeleteProjectBoard(ctx *context.Context) { + if ctx.User == nil { + ctx.JSON(403, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(models.AccessModeWrite, models.UnitTypeProjects) { + ctx.JSON(403, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return + } + + project, err := models.GetProjectByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + + pb, err := models.GetProjectBoard(ctx.ParamsInt64(":boardID")) + if err != nil { + ctx.InternalServerError(err) + return + } + if pb.ProjectID != ctx.ParamsInt64(":id") { + ctx.JSON(422, map[string]string{ + "message": fmt.Sprintf("ProjectBoard[%d] is not in Project[%d] as expected", pb.ID, project.ID), + }) + return + } + + if project.RepoID != ctx.Repo.Repository.ID { + ctx.JSON(422, map[string]string{ + "message": fmt.Sprintf("ProjectBoard[%d] is not in Repository[%d] as expected", pb.ID, ctx.Repo.Repository.ID), + }) + return + } + + if err := models.DeleteProjectBoardByID(ctx.ParamsInt64(":boardID")); err != nil { + ctx.ServerError("DeleteProjectBoardByID", err) + return + } + + ctx.JSON(200, map[string]interface{}{ + "ok": true, + }) +} + +// AddBoardToProjectPost allows a new board to be added to a project. +func AddBoardToProjectPost(ctx *context.Context, form auth.EditProjectBoardTitleForm) { + + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(models.AccessModeWrite, models.UnitTypeProjects) { + ctx.JSON(403, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return + } + + project, err := models.GetProjectByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + + if err := models.NewProjectBoard(&models.ProjectBoard{ + ProjectID: project.ID, + Title: form.Title, + CreatorID: ctx.User.ID, + }); err != nil { + ctx.ServerError("NewProjectBoard", err) + return + } + + ctx.JSON(200, map[string]interface{}{ + "ok": true, + }) +} + +// EditProjectBoardTitle allows a project board's title to be updated +func EditProjectBoardTitle(ctx *context.Context, form auth.EditProjectBoardTitleForm) { + + if ctx.User == nil { + ctx.JSON(403, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(models.AccessModeWrite, models.UnitTypeProjects) { + ctx.JSON(403, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return + } + + project, err := models.GetProjectByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + + board, err := models.GetProjectBoard(ctx.ParamsInt64(":boardID")) + if err != nil { + ctx.InternalServerError(err) + return + } + if board.ProjectID != ctx.ParamsInt64(":id") { + ctx.JSON(422, map[string]string{ + "message": fmt.Sprintf("ProjectBoard[%d] is not in Project[%d] as expected", board.ID, project.ID), + }) + return + } + + if project.RepoID != ctx.Repo.Repository.ID { + ctx.JSON(422, map[string]string{ + "message": fmt.Sprintf("ProjectBoard[%d] is not in Repository[%d] as expected", board.ID, ctx.Repo.Repository.ID), + }) + return + } + + if form.Title != "" { + board.Title = form.Title + } + + if err := models.UpdateProjectBoard(board); err != nil { + ctx.ServerError("UpdateProjectBoard", err) + return + } + + ctx.JSON(200, map[string]interface{}{ + "ok": true, + }) +} + +// MoveIssueAcrossBoards move a card from one board to another in a project +func MoveIssueAcrossBoards(ctx *context.Context) { + + if ctx.User == nil { + ctx.JSON(403, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(models.AccessModeWrite, models.UnitTypeProjects) { + ctx.JSON(403, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return + } + + p, err := models.GetProjectByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + var board *models.ProjectBoard + + if ctx.ParamsInt64(":boardID") == 0 { + + board = &models.ProjectBoard{ + ID: 0, + ProjectID: 0, + Title: ctx.Tr("repo.projects.type.uncategorized"), + } + + } else { + board, err = models.GetProjectBoard(ctx.ParamsInt64(":boardID")) + if err != nil { + if models.IsErrProjectBoardNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectBoard", err) + } + return + } + if board.ProjectID != p.ID { + ctx.NotFound("", nil) + return + } + } + + issue, err := models.GetIssueByID(ctx.ParamsInt64(":index")) + if err != nil { + if models.IsErrIssueNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetIssueByID", err) + } + + return + } + + if err := models.MoveIssueAcrossProjectBoards(issue, board); err != nil { + ctx.ServerError("MoveIssueAcrossProjectBoards", err) + return + } + + ctx.JSON(200, map[string]interface{}{ + "ok": true, + }) +} + +// CreateProject renders the generic project creation page +func CreateProject(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.projects.new") + ctx.Data["ProjectTypes"] = models.GetProjectsConfig() + + ctx.HTML(200, tplGenericProjectsNew) +} + +// CreateProjectPost creates an individual and/or organization project +func CreateProjectPost(ctx *context.Context, form auth.UserCreateProjectForm) { + + user := checkContextUser(ctx, form.UID) + if ctx.Written() { + return + } + + ctx.Data["ContextUser"] = user + + if ctx.HasError() { + ctx.HTML(200, tplGenericProjectsNew) + return + } + + var projectType = models.ProjectTypeIndividual + if user.IsOrganization() { + projectType = models.ProjectTypeOrganization + } + + if err := models.NewProject(&models.Project{ + Title: form.Title, + Description: form.Content, + CreatorID: user.ID, + BoardType: form.BoardType, + Type: projectType, + }); err != nil { + ctx.ServerError("NewProject", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title)) + ctx.Redirect(setting.AppSubURL + "/") +} diff --git a/routers/repo/pull.go b/routers/repo/pull.go index cfe30a1a19..ed70ec13a8 100644 --- a/routers/repo/pull.go +++ b/routers/repo/pull.go @@ -906,7 +906,7 @@ func CompareAndPullRequestPost(ctx *context.Context, form auth.CreateIssueForm) } defer headGitRepo.Close() - labelIDs, assigneeIDs, milestoneID := ValidateRepoMetas(ctx, form, true) + labelIDs, assigneeIDs, milestoneID, _ := ValidateRepoMetas(ctx, form, true) if ctx.Written() { return } diff --git a/routers/repo/setting.go b/routers/repo/setting.go index e03bf556be..8d07bf09a4 100644 --- a/routers/repo/setting.go +++ b/routers/repo/setting.go @@ -284,6 +284,15 @@ func SettingsPost(ctx *context.Context, form auth.RepoSettingForm) { } } + if form.EnableProjects && !models.UnitTypeProjects.UnitGlobalDisabled() { + units = append(units, models.RepoUnit{ + RepoID: repo.ID, + Type: models.UnitTypeProjects, + }) + } else if !models.UnitTypeProjects.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, models.UnitTypeProjects) + } + if form.EnablePulls && !models.UnitTypePullRequests.UnitGlobalDisabled() { units = append(units, models.RepoUnit{ RepoID: repo.ID, diff --git a/routers/routes/routes.go b/routers/routes/routes.go index 34157ea5ba..27af9275ed 100644 --- a/routers/routes/routes.go +++ b/routers/routes/routes.go @@ -275,6 +275,7 @@ func RegisterRoutes(m *macaron.Macaron) { ctx.Data["UnitWikiGlobalDisabled"] = models.UnitTypeWiki.UnitGlobalDisabled() ctx.Data["UnitIssuesGlobalDisabled"] = models.UnitTypeIssues.UnitGlobalDisabled() ctx.Data["UnitPullsGlobalDisabled"] = models.UnitTypePullRequests.UnitGlobalDisabled() + ctx.Data["UnitProjectsGlobalDisabled"] = models.UnitTypeProjects.UnitGlobalDisabled() }) // FIXME: not all routes need go through same middlewares. @@ -533,6 +534,7 @@ func RegisterRoutes(m *macaron.Macaron) { reqRepoPullsReader := context.RequireRepoReader(models.UnitTypePullRequests) reqRepoIssuesOrPullsWriter := context.RequireRepoWriterOr(models.UnitTypeIssues, models.UnitTypePullRequests) reqRepoIssuesOrPullsReader := context.RequireRepoReaderOr(models.UnitTypeIssues, models.UnitTypePullRequests) + reqRepoProjectsReader := context.RequireRepoReader(models.UnitTypeProjects) // ***** START: Organization ***** m.Group("/org", func() { @@ -750,6 +752,7 @@ func RegisterRoutes(m *macaron.Macaron) { m.Post("/labels", reqRepoIssuesOrPullsWriter, repo.UpdateIssueLabel) m.Post("/milestone", reqRepoIssuesOrPullsWriter, repo.UpdateIssueMilestone) + m.Post("/projects", reqRepoIssuesOrPullsWriter, repo.UpdateIssueProject) m.Post("/assignee", reqRepoIssuesOrPullsWriter, repo.UpdateIssueAssignee) m.Post("/request_review", reqRepoIssuesOrPullsReader, repo.UpdatePullReviewRequest) m.Post("/status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueStatus) @@ -772,7 +775,7 @@ func RegisterRoutes(m *macaron.Macaron) { Post(bindIgnErr(auth.CreateMilestoneForm{}), repo.NewMilestonePost) m.Get("/:id/edit", repo.EditMilestone) m.Post("/:id/edit", bindIgnErr(auth.CreateMilestoneForm{}), repo.EditMilestonePost) - m.Post("/:id/:action", repo.ChangeMilestonStatus) + m.Post("/:id/:action", repo.ChangeMilestoneStatus) m.Post("/delete", repo.DeleteMilestone) }, context.RepoMustNotBeArchived(), reqRepoIssuesOrPullsWriter, context.RepoRef()) m.Group("/pull", func() { @@ -853,6 +856,28 @@ func RegisterRoutes(m *macaron.Macaron) { m.Get("/milestones", reqRepoIssuesOrPullsReader, repo.Milestones) }, context.RepoRef()) + m.Group("/projects", func() { + m.Get("", repo.Projects) + m.Get("/new", repo.NewProject) + m.Post("/new", bindIgnErr(auth.CreateProjectForm{}), repo.NewRepoProjectPost) + m.Group("/:id", func() { + m.Get("", repo.ViewProject) + m.Post("", bindIgnErr(auth.EditProjectBoardTitleForm{}), repo.AddBoardToProjectPost) + m.Post("/delete", repo.DeleteProject) + + m.Get("/edit", repo.EditProject) + m.Post("/edit", bindIgnErr(auth.CreateProjectForm{}), repo.EditProjectPost) + m.Post("/^:action(open|close)$", repo.ChangeProjectStatus) + + m.Group("/:boardID", func() { + m.Put("", bindIgnErr(auth.EditProjectBoardTitleForm{}), repo.EditProjectBoardTitle) + m.Delete("", repo.DeleteProjectBoard) + + m.Post("/:index", repo.MoveIssueAcrossBoards) + }) + }) + }, reqRepoProjectsReader, repo.MustEnableProjects) + m.Group("/wiki", func() { m.Get("/?:page", repo.Wiki) m.Get("/_pages", repo.WikiPages) diff --git a/routers/user/home.go b/routers/user/home.go index 4e5fc3e4df..f7f1786b33 100644 --- a/routers/user/home.go +++ b/routers/user/home.go @@ -101,7 +101,7 @@ func retrieveFeeds(ctx *context.Context, options models.GetFeedsOptions) { ctx.Data["Feeds"] = actions } -// Dashboard render the dashborad page +// Dashboard render the dashboard page func Dashboard(ctx *context.Context) { ctxUser := getDashboardContextUser(ctx) if ctx.Written() { diff --git a/routers/user/profile.go b/routers/user/profile.go index 653d3cea22..8bf5cacc56 100644 --- a/routers/user/profile.go +++ b/routers/user/profile.go @@ -216,6 +216,16 @@ func Profile(ctx *context.Context) { } total = int(count) + case "projects": + ctx.Data["OpenProjects"], _, err = models.GetProjects(models.ProjectSearchOptions{ + Page: -1, + IsClosed: util.OptionalBoolFalse, + Type: models.ProjectTypeIndividual, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } default: repos, count, err = models.SearchRepository(&models.SearchRepoOptions{ ListOptions: models.ListOptions{ diff --git a/templates/base/head.tmpl b/templates/base/head.tmpl index 0760354e43..8c14ccfb6d 100644 --- a/templates/base/head.tmpl +++ b/templates/base/head.tmpl @@ -44,6 +44,7 @@ MaxTimeout: {{NotificationSettings.MaxTimeout}}, EventSourceUpdateTime: {{NotificationSettings.EventSourceUpdateTime}}, }, + PageIsProjects: {{if .PageIsProjects }}true{{else}}false{{end}}, {{if .RequireTribute}} tributeValues: Array.from(new Map([ {{ range .Participants }} diff --git a/templates/repo/header.tmpl b/templates/repo/header.tmpl index 494d54b428..1cfdb7287b 100644 --- a/templates/repo/header.tmpl +++ b/templates/repo/header.tmpl @@ -99,6 +99,15 @@ </a> {{end}} + {{ if and (not .UnitProjectsGlobalDisabled) (.Permission.CanRead $.UnitTypeProjects)}} + <a href="{{.RepoLink}}/projects" class="{{ if .IsProjectsPage }}active{{end}} item"> + {{svg "octicon-project" 16}} {{.i18n.Tr "repo.project_board"}} + <span class="ui {{if not .Repository.NumOpenProjects}}gray{{else}}blue{{end}} small label"> + {{.Repository.NumOpenProjects}} + </span> + </a> + {{ end }} + {{if and (.Permission.CanRead $.UnitTypeReleases) (not .IsEmptyRepo) }} <a class="{{if .PageIsReleaseList}}active{{end}} item" href="{{.RepoLink}}/releases"> {{svg "octicon-tag" 16}} {{.i18n.Tr "repo.releases"}} <span class="ui {{if not .NumReleases}}gray{{else}}blue{{end}} small label">{{.NumReleases}}</span> diff --git a/templates/repo/issue/new_form.tmpl b/templates/repo/issue/new_form.tmpl index 38ccccaf7b..8d7b3902e4 100644 --- a/templates/repo/issue/new_form.tmpl +++ b/templates/repo/issue/new_form.tmpl @@ -136,6 +136,64 @@ </div> </div> + {{if .IsProjectsEnabled}} + <div class="ui divider"></div> + + <input id="project_id" name="project_id" type="hidden" value="{{.project_id}}"> + <div class="ui {{if not .HasIssuesOrPullsWritePermission}}disabled{{end}} floating jump select-project dropdown"> + <span class="text"> + <strong>{{.i18n.Tr "repo.issues.new.projects"}}</strong> + {{if .HasIssuesOrPullsWritePermission}} + {{svg "octicon-gear" 16}} + {{end}} + </span> + <div class="menu"> + <div class="header" style="text-transform: none;font-size:16px;">{{.i18n.Tr "repo.issues.new.add_project_title"}}</div> + {{if or .OpenProjects .ClosedProjects}} + <div class="ui icon search input"> + <i class="search icon"></i> + <input type="text" placeholder="{{.i18n.Tr "repo.issues.filter_projects"}}"> + </div> + {{end}} + <div class="no-select item">{{.i18n.Tr "repo.issues.new.clear_projects"}}</div> + {{if and (not .OpenProjects) (not .ClosedProjects)}} + <div class="header" style="text-transform: none;font-size:14px;"> + {{.i18n.Tr "repo.issues.new.no_items"}} + </div> + {{else}} + {{if .OpenProjects}} + <div class="divider"></div> + <div class="header"> + {{svg "octicon-project" 16}} + {{.i18n.Tr "repo.issues.new.open_projects"}} + </div> + {{range .OpenProjects}} + <div class="item" data-id="{{.ID}}" data-href="{{$.RepoLink}}/projects/{{.ID}}">{{.Title}}</div> + {{end}} + {{end}} + {{if .ClosedProjects}} + <div class="divider"></div> + <div class="header"> + {{svg "octicon-project" 16}} + {{.i18n.Tr "repo.issues.new.closed_projects"}} + </div> + {{range .ClosedProjects}} + <a class="item" data-id="{{.ID}}" data-href="{{$.RepoLink}}/projects/{{.ID}}">{{.Title}}</a> + {{end}} + {{end}} + {{end}} + </div> + </div> + <div class="ui select-project list"> + <span class="no-select item {{if .Project}}hide{{end}}">{{.i18n.Tr "repo.issues.new.no_projects"}}</span> + <div class="selected"> + {{if .Project}} + <a class="item" href="{{.RepoLink}}/projects/{{.Project.ID}}">{{.Project.Title}}</a> + {{end}} + </div> + </div> + {{end}} + <div class="ui divider"></div> <input id="assignee_ids" name="assignee_ids" type="hidden" value="{{.assignee_ids}}"> @@ -176,4 +234,3 @@ </div> </div> </form> - diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl index 8e7829bf7c..850c5b9157 100644 --- a/templates/repo/issue/view_content/comments.tmpl +++ b/templates/repo/issue/view_content/comments.tmpl @@ -8,7 +8,7 @@ 18 = REMOVED_DEADLINE, 19 = ADD_DEPENDENCY, 20 = REMOVE_DEPENDENCY, 21 = CODE, 22 = REVIEW, 23 = ISSUE_LOCKED, 24 = ISSUE_UNLOCKED, 25 = TARGET_BRANCH_CHANGED, 26 = DELETE_TIME_MANUAL, 27 = REVIEW_REQUEST, 28 = MERGE_PULL_REQUEST, - 29 = PULL_PUSH_EVENT --> + 29 = PULL_PUSH_EVENT, 30 = PROJECT_CHANGED, 31 = PROJECT_BOARD_CHANGED --> {{if eq .Type 0}} <div class="timeline-item comment" id="{{.HashTag}}"> {{if .OriginalAuthor }} @@ -616,5 +616,26 @@ {{if not .IsForcePush}} {{template "repo/commits_list_small" dict "comment" . "root" $}} {{end}} + {{else if eq .Type 30}} + {{if not $.UnitProjectsGlobalDisabled}} + <div class="timeline-item event" id="{{.HashTag}}"> + <span class="badge">{{svg "octicon-project" 16}}</span> + <a class="ui avatar image" href="{{.Poster.HomeLink}}"> + <img src="{{.Poster.RelAvatarLink}}"> + </a> + <span class="text grey"> + <a class="author" href="{{.Poster.HomeLink}}">{{.Poster.GetDisplayName}}</a> + {{if gt .OldProjectID 0}} + {{if gt .ProjectID 0}} + {{$.i18n.Tr "repo.issues.change_project_at" (.OldProject.Title|Escape) (.Project.Title|Escape) $createdStr | Safe}} + {{else}} + {{$.i18n.Tr "repo.issues.remove_project_at" (.OldProject.Title|Escape) $createdStr | Safe}} + {{end}} + {{else if gt .ProjectID 0}} + {{$.i18n.Tr "repo.issues.add_project_at" (.Project.Title|Escape) $createdStr | Safe}} + {{end}} + </span> + </div> + {{end}} {{end}} {{end}} diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl index 9d897ad473..71e84de2fa 100644 --- a/templates/repo/issue/view_content/sidebar.tmpl +++ b/templates/repo/issue/view_content/sidebar.tmpl @@ -192,6 +192,48 @@ </div> </div> + {{if .IsProjectsEnabled}} + <div class="ui divider"></div> + + <div class="ui {{if or (not .HasIssuesOrPullsWritePermission) .Repository.IsArchived}}disabled{{end}} floating jump select-project dropdown"> + <span class="text"> + <strong>{{.i18n.Tr "repo.issues.new.projects"}}</strong> + {{svg "octicon-gear" 16}} + </span> + <div class="menu" data-action="update" data-issue-id="{{$.Issue.ID}}" data-update-url="{{$.RepoLink}}/issues/projects"> + <div class="no-select item">{{.i18n.Tr "repo.issues.new.clear_projects"}}</div> + {{if .OpenProjects}} + <div class="divider"></div> + <div class="header"> + {{svg "octicon-project" 16}} + {{.i18n.Tr "repo.issues.new.open_projects"}} + </div> + {{range .OpenProjects}} + <div class="item" data-id="{{.ID}}" data-href="{{$.RepoLink}}/projects/{{.ID}}">{{svg "octicon-project" 16}} {{.Title}}</div> + {{end}} + {{end}} + {{if .ClosedProjects}} + <div class="divider"></div> + <div class="header"> + {{svg "octicon-project" 16}} + {{.i18n.Tr "repo.issues.new.closed_projects"}} + </div> + {{range .ClosedProjects}} + <a class="item" data-id="{{.ID}}" data-href="{{$.RepoLink}}/projects/{{.ID}}">{{svg "octicon-project" 16}} {{.Title}}</a> + {{end}} + {{end}} + </div> + </div> + <div class="ui select-project list"> + <span class="no-select item {{if .Issue.ProjectID}}hide{{end}}">{{.i18n.Tr "repo.issues.new.no_projects"}}</span> + <div class="selected"> + {{if .Issue.ProjectID}} + <a class="item" href="{{.RepoLink}}/projects/{{.Issue.ProjectID}}">{{svg "octicon-project" 16}} {{.Issue.Project.Title}}</a> + {{end}} + </div> + </div> + {{end}} + <div class="ui divider"></div> <input id="assignee_id" name="assignee_id" type="hidden" value="{{.assignee_id}}"> diff --git a/templates/repo/projects/list.tmpl b/templates/repo/projects/list.tmpl new file mode 100644 index 0000000000..f48cf400ba --- /dev/null +++ b/templates/repo/projects/list.tmpl @@ -0,0 +1,99 @@ +{{template "base/head" .}} +<div class="repository milestones"> + {{template "repo/header" .}} + <div class="ui container"> + <div class="navbar"> + {{template "repo/issue/navbar" .}} + {{if and (or .CanWriteIssues .CanWritePulls) (not .Repository.IsArchived)}} + <div class="ui right"> + <a class="ui green button" href="{{$.Link}}/new">{{.i18n.Tr "repo.projects.new"}}</a> + </div> + {{end}} + </div> + <div class="ui divider"></div> + {{template "base/alert" .}} + <div class="ui tiny basic buttons"> + <a class="ui {{if not .IsShowClosed}}green active{{end}} basic button" href="{{.RepoLink}}/projects?state=open"> + {{svg "octicon-project" 16}} + {{.i18n.Tr "repo.issues.open_tab" .OpenCount}} + </a> + <a class="ui {{if .IsShowClosed}}red active{{end}} basic button" href="{{.RepoLink}}/projects?state=closed"> + {{svg "octicon-check" 16}} + {{.i18n.Tr "repo.milestones.close_tab" .ClosedCount}} + </a> + </div> + + <div class="ui right floated secondary filter menu"> + <!-- Sort --> + <div class="ui dropdown type jump item"> + <span class="text"> + {{.i18n.Tr "repo.issues.filter_sort"}} + <i class="dropdown icon"></i> + </span> + <div class="menu"> + <a class="{{if eq .SortType "oldest"}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&sort=oldest&state={{$.State}}">{{.i18n.Tr "repo.issues.filter_sort.oldest"}}</a> + <a class="{{if eq .SortType "recentupdate"}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&sort=recentupdate&state={{$.State}}">{{.i18n.Tr "repo.issues.filter_sort.recentupdate"}}</a> + <a class="{{if eq .SortType "leastupdate"}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&sort=leastupdate&state={{$.State}}">{{.i18n.Tr "repo.issues.filter_sort.leastupdate"}}</a> + </div> + </div> + </div> + <div class="milestone list"> + {{range .Projects}} + <li class="item"> + {{svg "octicon-project" 16}} <a href="{{$.RepoLink}}/projects/{{.ID}}">{{.Title}}</a> + <div class="meta"> + {{ $closedDate:= TimeSinceUnix .ClosedDateUnix $.Lang }} + {{if .IsClosed }} + {{svg "octicon-clock" 16}} {{$.i18n.Tr "repo.milestones.closed" $closedDate|Str2html}} + {{end}} + <span class="issue-stats"> + {{svg "octicon-issue-opened" 16}} {{$.i18n.Tr "repo.issues.open_tab" .NumOpenIssues}} + {{svg "octicon-issue-closed" 16}} {{$.i18n.Tr "repo.issues.close_tab" .NumClosedIssues}} + </span> + </div> + {{if and (or $.CanWriteIssues $.CanWritePulls) (not $.Repository.IsArchived)}} + <div class="ui right operate"> + <a href="{{$.Link}}/{{.ID}}/edit" data-id={{.ID}} data-title={{.Title}}>{{svg "octicon-pencil" 16}} {{$.i18n.Tr "repo.issues.label_edit"}}</a> + {{if .IsClosed}} + <a class="link-action" href data-url="{{$.Link}}/{{.ID}}/open">{{svg "octicon-check" 16}} {{$.i18n.Tr "repo.projects.open"}}</a> + {{else}} + <a class="link-action" href data-url="{{$.Link}}/{{.ID}}/close">{{svg "octicon-x" 16}} {{$.i18n.Tr "repo.projects.close"}}</a> + {{end}} + <a class="delete-button" href="#" data-url="{{$.RepoLink}}/projects/{{.ID}}/delete" data-id="{{.ID}}">{{svg "octicon-trashcan" 16}} {{$.i18n.Tr "repo.issues.label_delete"}}</a> + </div> + {{end}} + {{if .Description}} + <div class="content"> + {{.RenderedContent|Str2html}} + </div> + {{end}} + </li> + {{end}} + + {{template "base/paginate" .}} + </div> + </div> +</div> + +{{if or .CanWriteIssues .CanWritePulls}} +<div class="ui small basic delete modal"> + <div class="ui icon header"> + <i class="trash icon"></i> + {{.i18n.Tr "repo.projects.deletion"}} + </div> + <div class="content"> + <p>{{.i18n.Tr "repo.projects.deletion_desc"}}</p> + </div> + <div class="actions"> + <div class="ui red basic inverted cancel button"> + <i class="remove icon"></i> + {{.i18n.Tr "modal.no"}} + </div> + <div class="ui green basic inverted ok button"> + <i class="checkmark icon"></i> + {{.i18n.Tr "modal.yes"}} + </div> + </div> +</div> +{{end}} +{{template "base/footer" .}} diff --git a/templates/repo/projects/new.tmpl b/templates/repo/projects/new.tmpl new file mode 100644 index 0000000000..2da722bf9e --- /dev/null +++ b/templates/repo/projects/new.tmpl @@ -0,0 +1,70 @@ +{{template "base/head" .}} +<div class="repository new milestone"> + {{template "repo/header" .}} + <div class="ui container"> + <div class="navbar"> + {{template "repo/issue/navbar" .}} + {{if and (or .CanWriteIssues .CanWritePulls) .PageIsEditProject}} + <div class="ui right floated secondary menu"> + <a class="ui green button" href="{{$.RepoLink}}/projects/new">{{.i18n.Tr "repo.milestones.new"}}</a> + </div> + {{end}} + </div> + <div class="ui divider"></div> + <h2 class="ui dividing header"> + {{if .PageIsEditProjects}} + {{.i18n.Tr "repo.projects.edit"}} + <div class="sub header">{{.i18n.Tr "repo.projects.edit_subheader"}}</div> + {{else}} + {{.i18n.Tr "repo.projects.new"}} + <div class="sub header">{{.i18n.Tr "repo.projects.new_subheader"}}</div> + {{end}} + </h2> + {{template "base/alert" .}} + <form class="ui form grid" action="{{.Link}}" method="post"> + {{.CsrfTokenHtml}} + <div class="eleven wide column"> + <div class="field {{if .Err_Title}}error{{end}}"> + <label>{{.i18n.Tr "repo.projects.title"}}</label> + <input name="title" placeholder="{{.i18n.Tr "repo.projects.title"}}" value="{{.title}}" autofocus required> + </div> + <div class="field"> + <label>{{.i18n.Tr "repo.projects.desc"}}</label> + <textarea name="content">{{.content}}</textarea> + </div> + + {{if not .PageIsEditProjects}} + <label>{{.i18n.Tr "repo.projects.template.desc"}}</label> + <div class="ui selection dropdown"> + <input type="hidden" name="board_type" value="{{.type}}"> + <div class="default text">{{.i18n.Tr "repo.projects.template.desc_helper"}}</div> + <div class="menu"> + {{range $element := .ProjectTypes}} + <div class="item" data-id="{{$element.BoardType}}" data-value="{{$element.BoardType}}">{{$.i18n.Tr $element.Translation}}</div> + {{end}} + </div> + </div> + {{end}} + </div> + <div class="ui container"> + <div class="ui divider"></div> + <div class="ui left"> + {{if .PageIsEditProjects}} + <a class="ui blue basic button" href="{{.RepoLink}}/projects"> + {{.i18n.Tr "repo.milestones.cancel"}} + </a> + <button class="ui green button"> + {{.i18n.Tr "repo.projects.modify"}} + </button> + {{else}} + <button class="ui green button"> + {{.i18n.Tr "repo.projects.create"}} + </button> + {{end}} + </div> + </div> + + </form> + </div> +</div> +{{template "base/footer" .}} diff --git a/templates/repo/projects/view.tmpl b/templates/repo/projects/view.tmpl new file mode 100644 index 0000000000..21c6f01733 --- /dev/null +++ b/templates/repo/projects/view.tmpl @@ -0,0 +1,153 @@ +{{template "base/head" .}} +<div class="repository"> + {{template "repo/header" .}} + <div class="ui container"> + <div class="ui three column stackable grid"> + <div class="column"> + {{template "repo/issue/navbar" .}} + </div> + <div class="column center aligned"> + {{template "repo/issue/search" .}} + </div> + <div class="column right aligned"> + {{if .PageIsProjects}} + <a class="ui green button show-modal item" data-modal="#new-board-item">{{.i18n.Tr "new_project_board"}}</a> + {{end}} + + <div class="ui small modal" id="new-board-item"> + <div class="header"> + {{$.i18n.Tr "repo.projects.board.new"}} + </div> + <div class="content"> + <form class="ui form"> + <div class="required field"> + <label for="new_board">{{$.i18n.Tr "repo.projects.board.new_title"}}</label> + <input class="new-board" id="new_board" name="title" required> + </div> + + <div class="text right actions"> + <div class="ui cancel button">{{$.i18n.Tr "settings.cancel"}}</div> + <button data-url="{{$.RepoLink}}/projects/{{$.Project.ID}}" class="ui green button" id="new_board_submit">{{$.i18n.Tr "repo.projects.board.new_submit"}}</button> + </div> + </form> + </div> + </div> + </div> + </div> + <div class="ui divider"></div> + </div> + + <div class="ui container fluid padded" id="project-board"> + + <div class="board"> + {{ range $board := .Boards }} + + <div class="ui segment board-column"> + <div class="board-column-header"> + <div class="ui large label board-label">{{.Title}}</div> + + {{ if $.IsSigned }} + {{ if not (eq .ID 0) }} + <div class="ui dropdown jump item poping up right" data-variation="tiny inverted"> + <span class="ui text"> + <img class="ui tiny avatar image" width="24" height="24"> + <span class="fitted not-mobile" tabindex="-1">{{svg "octicon-kebab-horizontal" 24}}</span> + </span> + <div class="menu user-menu" tabindex="-1"> + <a class="item show-modal button" data-modal="#edit-project-board-modal-{{.ID}}"> + {{svg "octicon-pencil" 16}} + {{$.i18n.Tr "repo.projects.board.edit"}} + </a> + <a class="item show-modal button" data-modal="#delete-board-modal-{{.ID}}"> + {{svg "octicon-trashcan" 16}} + {{$.i18n.Tr "repo.projects.board.delete"}} + </a> + + <div class="ui small modal edit-project-board" id="edit-project-board-modal-{{.ID}}"> + <div class="header"> + {{$.i18n.Tr "repo.projects.board.edit"}} + </div> + <div class="content"> + <form class="ui form"> + <div class="required field"> + <label for="new_board_title">{{$.i18n.Tr "repo.projects.board.edit_title"}}</label> + <input class="project-board-title" id="new_board_title" name="title" value="{{.Title}}" required> + </div> + + <div class="text right actions"> + <div class="ui cancel button">{{$.i18n.Tr "settings.cancel"}}</div> + <button data-url="{{$.RepoLink}}/projects/{{$.Project.ID}}/{{.ID}}" class="ui red button">{{$.i18n.Tr "repo.projects.board.edit"}}</button> + </div> + </form> + </div> + </div> + + <div class="ui basic modal" id="delete-board-modal-{{.ID}}"> + <div class="ui icon header"> + {{$.i18n.Tr "repo.projects.board.delete"}} + </div> + <div class="content center"> + <input type="hidden" name="action" value="delete"> + <div class="field"> + <label> + {{$.i18n.Tr "repo.projects.board.deletion_desc"}} + </label> + </div> + </div> + <form class="ui form" method="post"> + <div class="text right actions"> + <div class="ui cancel button">{{$.i18n.Tr "settings.cancel"}}</div> + <button class="ui red button delete-project-board" data-url="{{$.RepoLink}}/projects/{{$.Project.ID}}/{{.ID}}">{{$.i18n.Tr "repo.projects.board.delete"}}</button> + </div> + </form> + </div> + </div> + </div> + {{ end }} + {{ end }} + </div> + <div class="ui divider"></div> + + <div class="ui cards board" data-url="{{$.RepoLink}}/projects/{{$.Project.ID}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="board_{{.ID}}"> + + {{ range .Issues }} + + <!-- start issue card --> + <div class="card board-card" data-issue="{{.ID}}"> + <div class="content"> + <div class="header"> + <span class="{{if .IsClosed}}red{{else}}green{{end}}"> + {{if .IsPull}}{{svg "octicon-git-merge" 16}} + {{else if .IsClosed}}{{svg "octicon-issue-closed" 16}} + {{else}}{{svg "octicon-issue-opened" 16}} + {{end}} + </span> + <a class="project-board-title" href="{{$.RepoLink}}/issues/{{.Index}}">#{{.Index}} {{.Title}}</a> + </div> + <div class="meta"> + {{ if .MilestoneID }} + <a class="milestone" href="{{$.RepoLink}}/milestone/{{ .MilestoneID}}"> + {{svg "octicon-milestone" 16}} {{ .Milestone.Name }} + </a> + {{ end }} + </div> + </div> + <div class="extra content"> + {{ range .Labels }} + <a class="ui label has-emoji" href="{{$.RepoLink}}/issues?labels={{.ID}}" style="color: {{.ForegroundColor}}; background-color: {{.Color}}; margin-bottom: 3px;" title="{{.Description}}">{{.Name}}</a> + {{ end }} + </div> + </div> + <!-- stop issue card --> + + {{ end }} + </div> + </div> + {{ end }} + </div> + + </div> + +</div> + +{{template "base/footer" .}} diff --git a/templates/repo/settings/options.tmpl b/templates/repo/settings/options.tmpl index 5666b8f956..d2d51fe6ea 100644 --- a/templates/repo/settings/options.tmpl +++ b/templates/repo/settings/options.tmpl @@ -269,6 +269,21 @@ </div> </div> + <div class="ui divider"></div> + + {{$isProjectsEnabled := .Repository.UnitEnabled $.UnitTypeProjects}} + <div class="inline field"> + <label>{{.i18n.Tr "repo.project_board"}}</label> + {{if .UnitTypeProjects.UnitGlobalDisabled}} + <div class="ui checkbox poping up disabled" data-content="{{.i18n.Tr "repo.unit_disabled"}}"> + {{else}} + <div class="ui checkbox"> + {{end}} + <input class="enable-system" name="enable_projects" type="checkbox" {{if $isProjectsEnabled}}checked{{end}}> + <label>{{.i18n.Tr "repo.settings.projects_desc"}}</label> + </div> + </div> + {{if .Repository.CanEnablePulls}} <div class="ui divider"></div> {{$pullRequestEnabled := .Repository.UnitEnabled $.UnitTypePullRequests}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index d5e5c86cd8..ec4570b488 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -12487,6 +12487,11 @@ "type": "boolean", "x-go-name": "HasIssues" }, + "has_projects": { + "description": "either `true` to enable project unit, or `false` to disable them.", + "type": "boolean", + "x-go-name": "HasProjects" + }, "has_pull_requests": { "description": "either `true` to allow pull requests, or `false` to prevent pull request.", "type": "boolean", @@ -14271,6 +14276,10 @@ "type": "boolean", "x-go-name": "HasIssues" }, + "has_projects": { + "type": "boolean", + "x-go-name": "HasProjects" + }, "has_pull_requests": { "type": "boolean", "x-go-name": "HasPullRequests" diff --git a/templates/user/profile.tmpl b/templates/user/profile.tmpl index f453898f94..2de8dc95b5 100644 --- a/templates/user/profile.tmpl +++ b/templates/user/profile.tmpl @@ -82,7 +82,7 @@ </div> <div class="ui eleven wide column"> <div class="ui secondary stackable pointing menu"> - <a class='{{if and (ne .TabName "activity") (ne .TabName "following") (ne .TabName "followers") (ne .TabName "stars")}}active{{end}} item' href="{{.Owner.HomeLink}}"> + <a class='{{if and (ne .TabName "activity") (ne .TabName "following") (ne .TabName "followers") (ne .TabName "stars") (ne .TabName "projects")}}active{{end}} item' href="{{.Owner.HomeLink}}"> {{svg "octicon-repo" 16}} {{.i18n.Tr "user.repositories"}} </a> <a class='{{if eq .TabName "activity"}}active{{end}} item' href="{{.Owner.HomeLink}}?tab=activity"> diff --git a/templates/user/project.tmpl b/templates/user/project.tmpl new file mode 100644 index 0000000000..662544a366 --- /dev/null +++ b/templates/user/project.tmpl @@ -0,0 +1,68 @@ +{{template "base/head" .}} +<div class="repository new repo"> + <div class="ui middle very relaxed page grid"> + <div class="column"> + <form class="ui form" action="{{.Link}}" method="post"> + {{.CsrfTokenHtml}} + <h3 class="ui top attached header"> + {{.i18n.Tr "new_project"}} + </h3> + <div class="ui attached segment"> + {{template "base/alert" .}} + <div class="inline required field {{if .Err_Owner}}error{{end}}"> + <label>{{.i18n.Tr "repo.owner"}}</label> + <div class="ui selection owner dropdown"> + <input type="hidden" id="uid" name="uid" value="{{.ContextUser.ID}}" required> + <span class="text" title="{{.ContextUser.Name}}"> + <img class="ui mini image" src="{{.ContextUser.RelAvatarLink}}"> + {{.ContextUser.ShortName 20}} + </span> + <i class="dropdown icon"></i> + <div class="menu"> + <div class="item" data-value="{{.SignedUser.ID}}" title="{{.SignedUser.Name}}"> + <img class="ui mini image" src="{{.SignedUser.RelAvatarLink}}"> {{.SignedUser.ShortName 20}} + </div> + {{range .Orgs}} + <div class="item" data-value="{{.ID}}" title="{{.Name}}"> + <img class="ui mini image" src="{{.RelAvatarLink}}"> {{.ShortName 20}} + </div> + {{end}} + </div> + </div> + </div> + + <div class="inline field {{if .Err_Title}}error{{end}}"> + <label>{{.i18n.Tr "repo.projects.title"}}</label> + <input name="title" placeholder="{{.i18n.Tr "repo.projects.title"}}" value="{{.title}}" autofocus required> + </div> + <div class="inline field"> + <label>{{.i18n.Tr "repo.projects.desc"}}</label> + <textarea name="content">{{.content}}</textarea> + </div> + + <div class="inline field"> + <label>{{.i18n.Tr "repo.projects.template.desc"}}</label> + <div class="ui selection dropdown"> + <input type="hidden" name="board_type" value="{{.type}}"> + <div class="default text">{{.i18n.Tr "repo.projects.template.desc_helper"}}</div> + <div class="menu"> + {{range $element := .ProjectTypes}} + <div class="item" data-id="{{$element.BoardType}}" data-value="{{$element.BoardType}}">{{$.i18n.Tr $element.Translation}}</div> + {{end}} + </div> + </div> + </div> + + <div class="inline field"> + <label></label> + <button class="ui green button"> + {{.i18n.Tr "repo.projects.create" }} + </button> + <a class="ui button" href="{{AppSubUrl}}/">{{.i18n.Tr "cancel"}}</a> + </div> + </div> + </form> + </div> + </div> +</div> +{{template "base/footer" .}} diff --git a/web_src/js/features/projects.js b/web_src/js/features/projects.js new file mode 100644 index 0000000000..13318c9f89 --- /dev/null +++ b/web_src/js/features/projects.js @@ -0,0 +1,99 @@ +const {csrf} = window.config; + +export default async function initProject() { + if (!window.config || !window.config.PageIsProjects) { + return; + } + + const {Sortable} = await import(/* webpackChunkName: "sortable" */'sortablejs'); + const boardColumns = document.getElementsByClassName('board-column'); + + for (const column of boardColumns) { + new Sortable( + column.getElementsByClassName('board')[0], + { + group: 'shared', + animation: 150, + onAdd: (e) => { + $.ajax(`${e.to.dataset.url}/${e.item.dataset.issue}`, { + headers: { + 'X-Csrf-Token': csrf, + 'X-Remote': true, + }, + contentType: 'application/json', + type: 'POST', + error: () => { + e.from.insertBefore(e.item, e.from.children[e.oldIndex]); + }, + }); + }, + } + ); + } + + $('.edit-project-board').each(function () { + const projectTitleLabel = $(this).closest('.board-column-header').find('.board-label'); + const projectTitleInput = $(this).find( + '.content > .form > .field > .project-board-title' + ); + + $(this) + .find('.content > .form > .actions > .red') + .on('click', function (e) { + e.preventDefault(); + + $.ajax({ + url: $(this).data('url'), + data: JSON.stringify({title: projectTitleInput.val()}), + headers: { + 'X-Csrf-Token': csrf, + 'X-Remote': true, + }, + contentType: 'application/json', + method: 'PUT', + }).done(() => { + projectTitleLabel.text(projectTitleInput.val()); + projectTitleInput.closest('form').removeClass('dirty'); + $('.ui.modal').modal('hide'); + }); + }); + }); + + $('.delete-project-board').each(function () { + $(this).click(function (e) { + e.preventDefault(); + + $.ajax({ + url: $(this).data('url'), + headers: { + 'X-Csrf-Token': csrf, + 'X-Remote': true, + }, + contentType: 'application/json', + method: 'DELETE', + }).done(() => { + setTimeout(window.location.reload(true), 2000); + }); + }); + }); + + $('#new_board_submit').click(function (e) { + e.preventDefault(); + + const boardTitle = $('#new_board'); + + $.ajax({ + url: $(this).data('url'), + data: JSON.stringify({title: boardTitle.val()}), + headers: { + 'X-Csrf-Token': csrf, + 'X-Remote': true, + }, + contentType: 'application/json', + method: 'POST', + }).done(() => { + boardTitle.closest('form').removeClass('dirty'); + setTimeout(window.location.reload(true), 2000); + }); + }); +} diff --git a/web_src/js/index.js b/web_src/js/index.js index 32fb340dcb..a1b5035764 100644 --- a/web_src/js/index.js +++ b/web_src/js/index.js @@ -12,6 +12,7 @@ import initContextPopups from './features/contextpopup.js'; import initGitGraph from './features/gitgraph.js'; import initClipboard from './features/clipboard.js'; import initUserHeatmap from './features/userheatmap.js'; +import initProject from './features/projects.js'; import initServiceWorker from './features/serviceworker.js'; import initMarkdownAnchors from './markdown/anchors.js'; import renderMarkdownContent from './markdown/content.js'; @@ -527,6 +528,10 @@ function initCommentForm() { $list.find('.selected').html(`<a class="item" href=${$(this).data('href')}>${ htmlEscape($(this).text())}</a>`); break; + case '#project_id': + $list.find('.selected').html(`<a class="item" href=${$(this).data('href')}>${ + htmlEscape($(this).text())}</a>`); + break; case '#assignee_id': $list.find('.selected').html(`<a class="item" href=${$(this).data('href')}>` + `<img class="ui avatar image" src=${$(this).data('avatar')}>${ @@ -556,7 +561,8 @@ function initCommentForm() { }); } - // Milestone and assignee + // Milestone, Assignee, Project + selectItem('.select-project', '#project_id'); selectItem('.select-milestone', '#milestone_id'); selectItem('.select-assignee', '#assignee_id'); } @@ -2485,6 +2491,7 @@ $(document).ready(async () => { initGitGraph(), initClipboard(), initUserHeatmap(), + initProject(), initServiceWorker(), initNotificationCount(), renderMarkdownContent(), diff --git a/web_src/less/_repository.less b/web_src/less/_repository.less index 4c3861c5c3..aaf729a8e1 100644 --- a/web_src/less/_repository.less +++ b/web_src/less/_repository.less @@ -3019,6 +3019,86 @@ tbody.commit-list { vertical-align: middle; } +.board { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + overflow-x: auto; + margin: 0 .5em; +} + +.board-column { + background-color: rgba(0, 0, 0, .05) !important; + border: 1px solid rgba(34, 36, 38, .15) !important; + margin: 0 .5rem !important; + padding: .5rem !important; + width: 320px; + height: 60vh; + overflow-y: scroll; + flex: 0 0 auto; + overflow: visible; + display: flex; + flex-direction: column; +} + +.board-column-header { + display: flex; + justify-content: space-between; +} + +.board-label { + background: none !important; + line-height: 1.25 !important; +} + +.board-column > .cards { + flex: 1; + display: flex; + flex-direction: column; + margin: 0 !important; + padding: 0 !important; + + .card .meta > a.milestone { + color: #999999; + } +} + +.board-column > .divider { + margin: 5px 0; +} + +.board-column:first-child { + margin-left: auto !important; +} + +.board-column:last-child { + margin-right: auto !important; +} + +.board-card { + margin: 3px !important; + width: auto !important; + background-color: #fff; + border-radius: 5px; + cursor: pointer; +} + +.board-card .header { + font-size: 1.1em !important; +} + +.board-card .content { + padding: 5px 8px !important; +} + +.board-card .extra.content { + padding: 5px 8px !important; +} + +td.blob-excerpt { + background-color: #fafafa; +} + .issue-keyword { border-bottom: 1px dotted #959da5; display: inline-block; @@ -3082,3 +3162,13 @@ tbody.commit-list { } } } + +.select-project .item { + color: inherit; + display: inline-flex; + align-items: center; +} + +.select-project .item .svg { + margin-right: .5rem; +} diff --git a/web_src/less/themes/theme-arc-green.less b/web_src/less/themes/theme-arc-green.less index 1e8c118675..f9b643e969 100644 --- a/web_src/less/themes/theme-arc-green.less +++ b/web_src/less/themes/theme-arc-green.less @@ -1910,6 +1910,10 @@ footer .container .links > * { border-bottom-color: #404552; } +.board-column { + background-color: rgba(0, 0, 0, .2) !important; +} + .tribute-container { box-shadow: 0 .25rem .5rem rgba(0, 0, 0, .6); } |