diff --git a/custom/conf/app.ini.sample b/custom/conf/app.ini.sample index 0c29932d92af8..15771ebbfd142 100644 --- a/custom/conf/app.ini.sample +++ b/custom/conf/app.ini.sample @@ -43,13 +43,16 @@ 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 +; Default templates for project borards +PROJECT_BOARD_BASIC_KANBAN_TYPE = To Do, In Progress, Done +PROJECT_BOARD_BUG_TRIAGE_TYPE = Needs Triage, High priority, Low priority, Closed [repository.editor] ; List of file extensions for which lines should be wrapped in the CodeMirror editor 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 0000000000000..cb089cd89a7d7 --- /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 0000000000000..07d359d07cf1e --- /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 0000000000000..498b267a8c781 --- /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 0000000000000..a5d7b84a67345 --- /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 0000000000000..b58d1184a9d43 --- /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 0000000000000..14ed0aa42de0f --- /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 $/; }; + + # 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 0000000000000..ec17ec1939b7c --- /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 0000000000000..4142082bcb939 --- /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 0000000000000..e144712c85c05 --- /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 0000000000000..399eab1924e39 --- /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 0000000000000..6187dbf4390fc --- /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: +# +# +# +# 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 0000000000000..6cbef5c370d8c --- /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 0000000000000..a1fd29ec14823 --- /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 0000000000000..10fa14c5ab013 --- /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 0000000000000..5014c4b31cb97 --- /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 )" >&2 + exit 1 +fi + +if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then + echo "usage: $0 " >&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 0000000000000..a5196d1be8fb5 --- /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 new file mode 100644 index 0000000000000..76d765ea90da1 Binary files /dev/null and b/integrations/gitea-repositories-meta/user5/repo4.git/objects/16/dfebd1ed3905d78d7e061e945fc9c34afe4e81 differ diff --git a/integrations/gitea-repositories-meta/user5/repo4.git/objects/c1/202ad022ae7d3a6d2474dc76d5a0c8e87cdc0f b/integrations/gitea-repositories-meta/user5/repo4.git/objects/c1/202ad022ae7d3a6d2474dc76d5a0c8e87cdc0f new file mode 100644 index 0000000000000..f63d6019b8ed4 Binary files /dev/null and b/integrations/gitea-repositories-meta/user5/repo4.git/objects/c1/202ad022ae7d3a6d2474dc76d5a0c8e87cdc0f differ diff --git a/integrations/gitea-repositories-meta/user5/repo4.git/objects/c7/cd3cd144e6d23c9d6f3d07e52b2c1a956e0338 b/integrations/gitea-repositories-meta/user5/repo4.git/objects/c7/cd3cd144e6d23c9d6f3d07e52b2c1a956e0338 new file mode 100644 index 0000000000000..c8d7c54d58cd9 Binary files /dev/null and b/integrations/gitea-repositories-meta/user5/repo4.git/objects/c7/cd3cd144e6d23c9d6f3d07e52b2c1a956e0338 differ 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 0000000000000..5fd26e37da27f --- /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 e69d9306edd83..8e4091ef7d53a 100644 --- a/integrations/links_test.go +++ b/integrations/links_test.go @@ -35,6 +35,9 @@ func TestLinksNoLogin(t *testing.T) { "/api/v1/swagger", // TODO: follow this page and test every link "/vendor/librejs.html", + "/user2/repo1", + "/user2/repo1/projects", + "/user2/repo1/projects/1", } for _, link := range links { @@ -60,6 +63,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 7370bd1571e56..8d3265a74ecd3 100644 --- a/models/error.go +++ b/models/error.go @@ -1549,6 +1549,43 @@ 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 +} + +// 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 0000000000000..3d42597c5e8ac --- /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 0000000000000..9e06e8c23960b --- /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_issues.yml b/models/fixtures/project_issues.yml new file mode 100644 index 0000000000000..b1af05908aafb --- /dev/null +++ b/models/fixtures/project_issues.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 35b9b92b79525..726abf9af97e1 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 3b86dd0f81f91..a44e480270ed7 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 263655c08944c..a8d41b6118cfd 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:"-"` @@ -275,6 +276,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 } @@ -1059,6 +1064,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_issues", "issue.id = project_issues.issue_id"). + And("project_issues.project_id=?", opts.ProjectID) + } + + if opts.ProjectBoardID != 0 { + if opts.ProjectBoardID > 0 { + sess.In("issue.id", builder.Select("issue_id").From("project_issues").Where(builder.Eq{"project_board_id": opts.ProjectBoardID})) + } else { + sess.In("issue.id", builder.Select("issue_id").From("project_issues").Where(builder.Eq{"project_board_id": 0})) + } + } + switch opts.IsPull { case util.OptionalBoolTrue: sess.And("issue.is_pull=?", true) diff --git a/models/issue_comment.go b/models/issue_comment.go index f7017435d77d9..d8222616b0fc9 100644 --- a/models/issue_comment.go +++ b/models/issue_comment.go @@ -90,6 +90,10 @@ const ( CommentTypeReviewRequest // merge pull request CommentTypeMergePull + // Project changed + CommentTypeProject + // Project board changed + CommentTypeProjectBoard ) // CommentTag defines comment tag type @@ -115,6 +119,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:"-"` @@ -342,6 +350,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 { @@ -557,6 +591,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, @@ -719,6 +755,8 @@ type CreateCommentOptions struct { DependentIssueID int64 OldMilestoneID int64 MilestoneID int64 + OldProjectID int64 + ProjectID int64 AssigneeID int64 RemovedAssignee bool OldTitle string diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 6868aad7b190a..c75fc8d135265 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -210,6 +210,8 @@ var migrations = []Migration{ NewMigration("Add Branch Protection Block Outdated Branch", addBlockOnOutdatedBranch), // v138 -> v139 NewMigration("Add ResolveDoerID to Comment table", addResolveDoerIDCommentColumn), + // v139 -> v140 + NewMigration("add projects info to repository table", addProjectsInfo), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v139.go b/models/migrations/v139.go new file mode 100644 index 0000000000000..b669edadc1660 --- /dev/null +++ b/models/migrations/v139.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 + } + + // ProjectIssues saves relation from issue to a project + type ProjectIssues struct { + ID int64 `xorm:"pk autoincr"` + IssueID int64 `xorm:"INDEX"` + ProjectID int64 `xorm:"INDEX"` + ProjectBoardID int64 `xorm:"INDEX"` + } + + if err := x.Sync2(new(ProjectIssues)); 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 c818c651007b4..b288c568e89c7 100644 --- a/models/models.go +++ b/models/models.go @@ -125,6 +125,9 @@ func init() { new(Task), new(LanguageStat), new(EmailHash), + new(Project), + new(ProjectBoard), + new(ProjectIssues), ) gonicNames := []string{"SSL", "UID"} diff --git a/models/project.go b/models/project.go new file mode 100644 index 0000000000000..3dbe170583ef8 --- /dev/null +++ b/models/project.go @@ -0,0 +1,292 @@ +// 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" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" +) + +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{ + {None, "repo.projects.type.none"}, + {BasicKanban, "repo.projects.type.basic_kanban"}, + {BugTriage, "repo.projects.type.bug_triage"}, + } +} + +// IsProjectTypeValid checks if a project typeis valid +func IsProjectTypeValid(p ProjectType) bool { + switch p { + case ProjectTypeRepository, ProjectTypeIndividual, ProjectTypeOrganization: + 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, error) { + + projects := make([]*Project, 0, setting.UI.IssuePagingNum) + + sess := x.Where("repo_id = ?", opts.RepoID) + switch opts.IsClosed { + case util.OptionalBoolTrue: + sess = sess.Where("is_closed = ?", true) + case util.OptionalBoolFalse: + sess = sess.Where("is_closed = ?", false) + } + + if opts.Type > 0 { + sess = sess.Where("type = ?", opts.Type) + } + + if opts.Page > 0 { + sess = sess.Limit(setting.UI.IssuePagingNum, (opts.Page-1)*setting.UI.IssuePagingNum) + } + + switch opts.SortType { + case "oldest": + sess.Desc("created_unix") + case "recentupdate": + sess.Desc("updated_unix") + case "leastupdate": + sess.Asc("updated_unix") + default: + sess.Asc("created_unix") + } + + return projects, sess.Find(&projects) +} + +// NewProject creates a new Project +func NewProject(p *Project) error { + if !IsProjectBoardTypeValid(p.BoardType) { + p.BoardType = None + } + + 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 { + p.UpdatedUnix = timeutil.TimeStampNow() + _, err := e.ID(p.ID).Cols( + "title", + "description", + "updated_unix", + ).Update(p) + return err +} + +func countRepoProjects(e Engine, repoID int64) (int64, error) { + return e.Where("repo_id=? AND type=?", repoID, ProjectTypeRepository). + Count(new(Project)) +} + +func countRepoClosedProjects(e Engine, repoID int64) (int64, error) { + return e. + Where("repo_id=? AND type=? AND is_closed=?", repoID, ProjectTypeRepository, true). + Count(new(Project)) +} + +// ChangeProjectStatus toggle a project between opened and closed +func ChangeProjectStatus(p *Project, isClosed bool) error { + + repo, err := GetRepositoryByID(p.RepoID) + if err != nil { + return err + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + p.IsClosed = isClosed + if _, err = sess.ID(p.ID).Cols("is_closed").Update(p); err != nil { + return err + } + + numProjects, err := countRepoProjects(sess, repo.ID) + if err != nil { + return err + } + + numClosedProjects, err := countRepoClosedProjects(sess, repo.ID) + if err != nil { + return err + } + + repo.NumProjects = int(numProjects) + repo.NumClosedProjects = int(numClosedProjects) + + if _, err = sess.ID(repo.ID).Cols("num_projects, num_closed_projects").Update(repo); err != nil { + return err + } + + return sess.Commit() +} + +// 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 + } + + if err := deleteProjectIssuesByProjectID(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 + } + + repo, err := getRepositoryByID(e, p.RepoID) + if err != nil { + return err + } + + if _, err = e.ID(p.ID).Delete(new(Project)); err != nil { + return err + } + + numProjects, err := countRepoProjects(e, repo.ID) + if err != nil { + return err + } + + numClosedProjects, err := countRepoClosedProjects(e, repo.ID) + if err != nil { + return err + } + + repo.NumProjects = int(numProjects) + repo.NumClosedProjects = int(numClosedProjects) + + if _, err = e.ID(repo.ID).Cols("num_projects, num_closed_projects").Update(repo); err != nil { + return err + } + + if _, err = e.Exec("UPDATE `issue` SET project_id = 0 WHERE project_id = ?", p.ID); err != nil { + return err + } + + return nil +} diff --git a/models/project_board.go b/models/project_board.go new file mode 100644 index 0000000000000..e6170e302d99c --- /dev/null +++ b/models/project_board.go @@ -0,0 +1,218 @@ +// 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 ( + // None is a project board type that has no predefined columns + None ProjectBoardType = iota + + // BasicKanban is a project board type that has basic predefined columns + BasicKanban + + // BugTriage is a project board type that has predefined columns suited to hunting down bugs + BugTriage +) + +// ProjectBoard is used to represent boards on a project +type ProjectBoard struct { + ID int64 `xorm:"pk autoincr"` + Title string + Default bool //if true it collects issues witch are not signed to a specific board jet + + 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 None, BasicKanban, BugTriage: + return true + default: + return false + } +} + +func createBoardsForProjectsType(sess *xorm.Session, project *Project) error { + + var items []string + + switch project.BoardType { + + case BugTriage: + items = setting.Repository.ProjectBoardBugTriageType + + case BasicKanban: + items = setting.Repository.ProjectBoardBasicKanbanType + + case None: + 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(), + UpdatedUnix: 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 +} + +// 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 { + board.UpdatedUnix = timeutil.TimeStampNow() + _, err := e.ID(board.ID).Cols( + "title", + "default", + "updated_unix", + ).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_issues.go b/models/project_issues.go new file mode 100644 index 0000000000000..ec314c79af885 --- /dev/null +++ b/models/project_issues.go @@ -0,0 +1,206 @@ +// 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" +) + +// ProjectIssues saves relation from issue to a project +type ProjectIssues 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(&ProjectIssues{}) + 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_issues", "project.id=project_issues.project_id"). + Where("project_issues.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 ProjectIssues + 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 ProjectIssues + 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_issues"). + Where("project_id=?", p.ID). + GroupBy("issue_id").Count("issue_id") + 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_issues"). + Join("INNER", "issue", "project_issues.issue_id=issue.id"). + Where("project_issues.project_id=? AND issue.is_closed=?", p.ID, true).Count("issue.id") + 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_issues"). + Join("INNER", "issue", "project_issues.issue_id=issue.id"). + Where("project_issues.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_issues.issue_id=?", issue.ID).Delete(&ProjectIssues{}); 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(&ProjectIssues{ + 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 ProjectIssues + 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_issues` 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 0000000000000..0918d5ef1fddb --- /dev/null +++ b/models/project_test.go @@ -0,0 +1,86 @@ +// 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: BasicKanban, + 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 438066e0da319..bfb34ade432d5 100644 --- a/models/repo.go +++ b/models/repo.go @@ -166,6 +166,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"` @@ -244,6 +247,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 @@ -303,6 +307,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 @@ -349,6 +355,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) @@ -386,6 +396,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, @@ -1575,46 +1586,69 @@ func DeleteRepository(doer *User, uid, repoID int64) error { return fmt.Errorf("deleteBeans: %v", err) } - deleteCond := builder.Select("id").From("issue").Where(builder.Eq{"repo_id": repoID}) + // Projects + + deleteRepoCond := builder.Select("id").From("project").Where(builder.Eq{"repo_id": repoID}) + + // Delete project boards for projects in this repository + if _, err = sess.In("project_id", deleteRepoCond). + Delete(&ProjectBoard{}); err != nil { + return err + } + + // Delete project issues for projects in this repository + if _, err = sess.In("project_id", deleteRepoCond). + Delete(&ProjectIssues{}); err != nil { + return err + } + + // delete projects in this repository + if err = deleteBeans(sess, &Project{RepoID: repoID}); err != nil { + return fmt.Errorf("deleteBeans: %v", err) + } + + // Issues + + deleteIssueCond := builder.Select("id").From("issue").Where(builder.Eq{"repo_id": repoID}) // Delete comments and attachments - if _, err = sess.In("issue_id", deleteCond). + if _, err = sess.In("issue_id", deleteIssueCond). Delete(&Comment{}); err != nil { return err } // Dependencies for issues in this repository - if _, err = sess.In("issue_id", deleteCond). + if _, err = sess.In("issue_id", deleteIssueCond). Delete(&IssueDependency{}); err != nil { return err } // Delete dependencies for issues in other repositories - if _, err = sess.In("dependency_id", deleteCond). + if _, err = sess.In("dependency_id", deleteIssueCond). Delete(&IssueDependency{}); err != nil { return err } - if _, err = sess.In("issue_id", deleteCond). + if _, err = sess.In("issue_id", deleteIssueCond). Delete(&IssueUser{}); err != nil { return err } - if _, err = sess.In("issue_id", deleteCond). + if _, err = sess.In("issue_id", deleteIssueCond). Delete(&Reaction{}); err != nil { return err } - if _, err = sess.In("issue_id", deleteCond). + if _, err = sess.In("issue_id", deleteIssueCond). Delete(&IssueWatch{}); err != nil { return err } - if _, err = sess.In("issue_id", deleteCond). + if _, err = sess.In("issue_id", deleteIssueCond). Delete(&Stopwatch{}); err != nil { return err } - if _, err = sess.In("issue_id", deleteCond). + if _, err = sess.In("issue_id", deleteIssueCond). Delete(&TrackedTime{}); err != nil { return err } @@ -1630,7 +1664,7 @@ func DeleteRepository(doer *User, uid, repoID int64) error { attachmentPaths = append(attachmentPaths, attachments[j].LocalPath()) } - if _, err = sess.In("issue_id", deleteCond). + if _, err = sess.In("issue_id", deleteIssueCond). Delete(&Attachment{}); err != nil { return err } diff --git a/models/repo_unit.go b/models/repo_unit.go index 42ce8f6c8dc98..d4c74515f7957 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 bd2e6b13a6277..939deba574824 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 6c3421e4f7d85..1577e098cf013 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(50)"` + Content string + BoardType models.ProjectBoardType +} + +// UserCreateProjectForm is a from for creating an individual or organization +// form. +type UserCreateProjectForm struct { + Title string `binding:"Required;MaxSize(50)"` + 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(50)"` +} + // _____ .__.__ __ // / \ |__| | ____ _______/ |_ ____ ____ ____ // / \ / \| | | _/ __ \ / ___/\ __\/ _ \ / \_/ __ \ diff --git a/modules/context/repo.go b/modules/context/repo.go index 841dcd960e14b..eba5a4a28bc53 100644 --- a/modules/context/repo.go +++ b/modules/context/repo.go @@ -814,5 +814,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/repository.go b/modules/setting/repository.go index 8af3eaaf46933..08e96dfa7f892 100644 --- a/modules/setting/repository.go +++ b/modules/setting/repository.go @@ -37,6 +37,8 @@ var ( DefaultCloseIssuesViaCommitsInAnyBranch bool EnablePushCreateUser bool EnablePushCreateOrg bool + ProjectBoardBasicKanbanType []string + ProjectBoardBugTriageType []string DisabledRepoUnits []string DefaultRepoUnits []string PrefixArchiveFiles bool @@ -101,6 +103,8 @@ var ( DefaultCloseIssuesViaCommitsInAnyBranch: false, EnablePushCreateUser: false, EnablePushCreateOrg: false, + ProjectBoardBasicKanbanType: []string{"To Do", "In Progress", "Done"}, + ProjectBoardBugTriageType: []string{"Needs Triage", "High priority", "Low priority", "Closed"}, DisabledRepoUnits: []string{}, DefaultRepoUnits: []string{}, PrefixArchiveFiles: true, diff --git a/modules/structs/repo.go b/modules/structs/repo.go index 70de9b74694f7..33988868769c1 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"` @@ -146,6 +147,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 fe685735298b7..84adf772dd0a5 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -51,6 +51,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 @@ -376,6 +378,7 @@ repositories = Repositories activity = Public Activity followers = Followers starred = Starred Repositories +projects = Projects following = Following follow = Follow unfollow = Unfollow @@ -731,6 +734,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 all repositories under this organization org_labels_desc_manage = manage @@ -830,6 +834,33 @@ 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?" + issues.desc = Organize bug reports, tasks and milestones. issues.filter_assignees = Filter Assignee issues.filter_milestones = Filter Milestone @@ -841,6 +872,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 @@ -868,9 +905,13 @@ issues.label_templates.fail_to_load_file = Failed to load label template file '% issues.add_label_at = added the
%s
label %s issues.remove_label_at = removed the
%s
label %s issues.add_milestone_at = `added this to the %s milestone %s` +issues.add_project_at = `added this to the %s project %s` issues.change_milestone_at = `modified the milestone from %s to %s %s` +issues.change_project_at = `modified the project from %s to %s %s` issues.remove_milestone_at = `removed this from the %s milestone %s` +issues.remove_project_at = `removed this from the %s 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 %s %s` issues.remove_assignee_at = `was unassigned by %s %s` @@ -1343,6 +1384,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 881a2c9a36484..1c446110f7906 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12431,6 +12431,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 31ba694e2e7db..e5cabb63a57cc 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "node": ">= 10.13.0" }, "dependencies": { + "sortablejs": "1.10.2", "@babel/core": "7.9.0", "@babel/plugin-proposal-object-rest-spread": "7.9.5", "@babel/plugin-transform-runtime": "7.9.0", diff --git a/public/vendor/plugins/sortable/sortable.min.js b/public/vendor/plugins/sortable/sortable.min.js new file mode 100644 index 0000000000000..693054f6255b0 --- /dev/null +++ b/public/vendor/plugins/sortable/sortable.min.js @@ -0,0 +1,2 @@ +/*! Sortable 1.10.0 - MIT | git://github.com/SortableJS/Sortable.git */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function o(t){return(o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function a(){return(a=Object.assign||function(t){for(var e=1;e"===e[0]&&(e=e.substring(1)),t)try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return!1}return!1}}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"===e[0]?t.parentNode===n&&h(t,e):h(t,e))||o&&t===n)return t;if(t===n)break}while(t=(i=t).host&&i!==document&&i.host.nodeType?i.host:i.parentNode)}var i;return null}var f,p=/\s+/g;function k(t,e,n){if(t&&e)if(t.classList)t.classList[n?"add":"remove"](e);else{var o=(" "+t.className+" ").replace(p," ").replace(" "+e+" "," ");t.className=(o+(n?" "+e:"")).replace(p," ")}}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];e in o||-1!==e.indexOf("webkit")||(e="-webkit-"+e),o[e]=n+("string"==typeof n?"":"px")}}function v(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform");o&&"none"!==o&&(n=o+" "+n)}while(!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix;return i&&new i(n)}function g(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=e.left-n&&r<=e.right+n,i=a>=e.top-n&&a<=e.bottom+n;return n&&o&&i?l=t:void 0}}),l}((t=t.touches?t.touches[0]:t).clientX,t.clientY);if(e){var n={};for(var o in t)t.hasOwnProperty(o)&&(n[o]=t[o]);n.target=n.rootEl=e,n.preventDefault=void 0,n.stopPropagation=void 0,e[j]._onDragOver(n)}}}function Pt(t){z&&z.parentNode[j]._isOutsideThisEl(t.target)}function kt(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[j]=this;var n={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return Mt(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==kt.supportPointer&&"PointerEvent"in window,emptyInsertThreshold:5};for(var o in O.initializePlugins(this,t,n),n)o in e||(e[o]=n[o]);for(var i in Ot(e),this)"_"===i.charAt(0)&&"function"==typeof this[i]&&(this[i]=this[i].bind(this));this.nativeDraggable=!e.forceFallback&&Tt,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?u(t,"pointerdown",this._onTapStart):(u(t,"mousedown",this._onTapStart),u(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(u(t,"dragover",this),u(t,"dragenter",this)),bt.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,T())}function Rt(t,e,n,o,i,r,a,l){var s,c,u=t[j],d=u.options.onMove;return!window.CustomEvent||w||E?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),d&&(c=d.call(u,s,a)),c}function Xt(t){t.draggable=!1}function Yt(){Dt=!1}function Bt(t){for(var e=t.tagName+t.className+t.src+t.href+t.textContent,n=e.length,o=0;n--;)o+=e.charCodeAt(n);return o.toString(36)}function Ft(t){return setTimeout(t,0)}function Ht(t){return clearTimeout(t)}kt.prototype={constructor:kt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(ht=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,z):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(function(t){St.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&St.push(o)}}(o),!z&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled||s.isContentEditable||(l=P(l,t.draggable,o,!1))&&l.animated||Z===l)){if(J=F(l),et=F(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return W({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),K("filter",n,{evt:e}),void(i&&e.cancelable&&e.preventDefault())}else if(c&&(c=c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return W({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),K("filter",n,{evt:e}),!0})))return void(i&&e.cancelable&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;if(n&&!z&&n.parentNode===r){var s=X(n);if(q=r,G=(z=n).parentNode,V=z.nextSibling,Z=n,ot=a.group,rt={target:kt.dragged=z,clientX:(e||t).clientX,clientY:(e||t).clientY},ct=rt.clientX-s.left,ut=rt.clientY-s.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,z.style["will-change"]="all",o=function(){K("delayEnded",i,{evt:t}),kt.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!c&&i.nativeDraggable&&(z.draggable=!0),i._triggerDragStart(t,e),W({sortable:i,name:"choose",originalEvent:t}),k(z,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){g(z,t.trim(),Xt)}),u(l,"dragover",It),u(l,"mousemove",It),u(l,"touchmove",It),u(l,"mouseup",i._onDrop),u(l,"touchend",i._onDrop),u(l,"touchcancel",i._onDrop),c&&this.nativeDraggable&&(this.options.touchStartThreshold=4,z.draggable=!0),K("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(E||w))o();else{if(kt.eventCanceled)return void this._onDrop();u(l,"mouseup",i._disableDelayedDrag),u(l,"touchend",i._disableDelayedDrag),u(l,"touchcancel",i._disableDelayedDrag),u(l,"mousemove",i._delayedDragTouchMoveHandler),u(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&u(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)}}},_delayedDragTouchMoveHandler:function(t){var e=t.touches?t.touches[0]:t;Math.max(Math.abs(e.clientX-this._lastX),Math.abs(e.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){z&&Xt(z),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;d(t,"mouseup",this._disableDelayedDrag),d(t,"touchend",this._disableDelayedDrag),d(t,"touchcancel",this._disableDelayedDrag),d(t,"mousemove",this._delayedDragTouchMoveHandler),d(t,"touchmove",this._delayedDragTouchMoveHandler),d(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?u(document,"pointermove",this._onTouchMove):u(document,e?"touchmove":"mousemove",this._onTouchMove):(u(z,"dragend",this),u(q,"dragstart",this._onDragStart));try{document.selection?Ft(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){if(vt=!1,q&&z){K("dragStarted",this,{evt:e}),this.nativeDraggable&&u(document,"dragover",Pt);var n=this.options;t||k(z,n.dragClass,!1),k(z,n.ghostClass,!0),kt.active=this,t&&this._appendGhost(),W({sortable:this,name:"start",originalEvent:e})}else this._nulling()},_emulateDragOver:function(){if(at){this._lastX=at.clientX,this._lastY=at.clientY,At();for(var t=document.elementFromPoint(at.clientX,at.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(at.clientX,at.clientY))!==e;)e=t;if(z.parentNode[j]._isOutsideThisEl(t),e)do{if(e[j]){if(e[j]._onDragOver({clientX:at.clientX,clientY:at.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}t=e}while(e=e.parentNode);Nt()}},_onTouchMove:function(t){if(rt){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=U&&v(U),a=U&&r&&r.a,l=U&&r&&r.d,s=_t&>&&b(gt),c=(i.clientX-rt.clientX+o.x)/(a||1)+(s?s[0]-Et[0]:0)/(a||1),u=(i.clientY-rt.clientY+o.y)/(l||1)+(s?s[1]-Et[1]:0)/(l||1);if(!kt.active&&!vt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))o.right+10||t.clientX<=o.right&&t.clientY>o.bottom&&t.clientX>=o.left:t.clientX>o.right&&t.clientY>o.top||t.clientX<=o.right&&t.clientY>o.bottom+10}(n,a,this)&&!g.animated){if(g===z)return A(!1);if(g&&l===n.target&&(s=g),s&&(i=X(s)),!1!==Rt(q,l,z,o,s,i,n,!!s))return O(),l.appendChild(z),G=l,N(),A(!0)}else if(s.parentNode===l){i=X(s);var v,m,b,y=z.parentNode!==l,w=!function(t,e,n){var o=n?t.left:t.top,i=n?t.right:t.bottom,r=n?t.width:t.height,a=n?e.left:e.top,l=n?e.right:e.bottom,s=n?e.width:e.height;return o===a||i===l||o+r/2===a+s/2}(z.animated&&z.toRect||o,s.animated&&s.toRect||i,a),E=a?"top":"left",D=Y(s,"top","top")||Y(z,"top","top"),S=D?D.scrollTop:void 0;if(ht!==s&&(m=i[E],yt=!1,wt=!w&&e.invertSwap||y),0!==(v=function(t,e,n,o,i,r,a,l){var s=o?t.clientY:t.clientX,c=o?n.height:n.width,u=o?n.top:n.left,d=o?n.bottom:n.right,h=!1;if(!a)if(l&&pt 0 { @@ -496,6 +530,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) @@ -510,7 +558,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 @@ -518,7 +566,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 @@ -527,7 +575,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) @@ -549,17 +597,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 @@ -567,17 +630,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 } } } @@ -587,7 +651,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 @@ -605,7 +669,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 } @@ -643,6 +707,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)) } @@ -740,6 +811,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 { @@ -821,6 +894,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 } @@ -959,6 +1034,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) @@ -1125,6 +1220,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.HTML(200, tplIssueView) diff --git a/routers/repo/milestone.go b/routers/repo/milestone.go index 5fbf929f3504b..e23f5b4dd84ab 100644 --- a/routers/repo/milestone.go +++ b/routers/repo/milestone.go @@ -197,8 +197,9 @@ 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) { +// ChangeMilestoneStatus response for change a milestone's status +// nolint: dupl +func ChangeMilestoneStatus(ctx *context.Context) { m, err := models.GetMilestoneByRepoID(ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")) if err != nil { if models.IsErrMilestoneNotExist(err) { @@ -262,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 0000000000000..2851f28f68071 --- /dev/null +++ b/routers/repo/projects.go @@ -0,0 +1,604 @@ +// 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" + + "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/timeutil" + "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.Query("sort") + + isShowClosed := ctx.Query("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, 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" + } + + pager := context.NewPagination(total, setting.UI.IssuePagingNum, page, 5) + 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" +// nolint: dupl +func ChangeProjectStatus(ctx *context.Context) { + p, err := models.GetProjectByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", err) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + switch ctx.Params(":action") { + case "open": + if p.IsClosed { + if err = models.ChangeProjectStatus(p, false); err != nil { + ctx.ServerError("ChangeProjectStatus", err) + return + } + } + ctx.Redirect(ctx.Repo.RepoLink + "/projects?state=open") + case "close": + if !p.IsClosed { + p.ClosedDateUnix = timeutil.TimeStampNow() + if err = models.ChangeProjectStatus(p, true); err != nil { + ctx.ServerError("ChangeProjectStatus", err) + return + } + } + ctx.Redirect(ctx.Repo.RepoLink + "/projects?state=closed") + + default: + ctx.Redirect(ctx.Repo.RepoLink + "/projects") + } +} + +// 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 call make 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 call make 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 call make 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 call make 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 call make 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 call make 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 call make 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 d23c93d0b658c..6a6a0f37ed6fe 100644 --- a/routers/repo/pull.go +++ b/routers/repo/pull.go @@ -891,7 +891,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 7a2db88c1f422..e4343a478d34e 100644 --- a/routers/repo/setting.go +++ b/routers/repo/setting.go @@ -283,6 +283,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 f3bd42f02acca..9360321889b94 100644 --- a/routers/routes/routes.go +++ b/routers/routes/routes.go @@ -274,6 +274,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. @@ -530,6 +531,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() { @@ -619,6 +621,19 @@ func RegisterRoutes(m *macaron.Macaron) { }, context.RepoIDAssignment(), context.UnitTypes(), reqRepoCodeReader) }, reqSignIn) + m.Group("/projects", func() { + m.Get("/create", repo.CreateProject) + m.Post("/create", bindIgnErr(auth.UserCreateProjectForm{}), repo.CreateProjectPost) + }, repo.MustEnableProjects, func(ctx *context.Context) { + + if err := ctx.User.GetOrganizations(&models.SearchOrganizationsOptions{All: true}); err != nil { + ctx.ServerError("GetOrganizations", err) + return + } + + ctx.Data["Orgs"] = ctx.User.Orgs + }) + // ***** Release Attachment Download without Signin m.Get("/:username/:reponame/releases/download/:vTag/:fileName", ignSignIn, context.RepoAssignment(), repo.MustBeNotEmpty, repo.RedirectDownload) @@ -744,6 +759,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) @@ -766,7 +782,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.Combo("/compare/*", repo.MustBeNotEmpty, reqRepoCodeReader, repo.SetEditorconfigIfExists). @@ -850,6 +866,23 @@ 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.Get("/:id", repo.ViewProject) + m.Post("/:id", bindIgnErr(auth.EditProjectBoardTitleForm{}), repo.AddBoardToProjectPost) + m.Get("/:id/:action", repo.ChangeProjectStatus) + m.Post("/:id/edit", bindIgnErr(auth.CreateProjectForm{}), repo.EditProjectPost) + m.Get("/:id/edit", repo.EditProject) + m.Post("/delete", repo.DeleteProject) + m.Combo("/:id/:boardID").Put(bindIgnErr(auth.EditProjectBoardTitleForm{}), repo.EditProjectBoardTitle). + Delete(repo.DeleteProjectBoard) + m.Post("/:id/:boardID/: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 816968562fd60..99426077d3fdd 100644 --- a/routers/user/home.go +++ b/routers/user/home.go @@ -100,7 +100,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 215dff0084b15..500092ad167da 100644 --- a/routers/user/profile.go +++ b/routers/user/profile.go @@ -209,6 +209,18 @@ 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 0ecf6821c32b9..3d1548bcda0db 100644 --- a/templates/base/head.tmpl +++ b/templates/base/head.tmpl @@ -99,6 +99,7 @@ TimeoutStep: {{NotificationSettings.TimeoutStep}}, MaxTimeout: {{NotificationSettings.MaxTimeout}}, }, + PageIsProjects: {{if .PageIsProjects }}true{{else}}false{{ end }}, {{if .RequireTribute}} tributeValues: [ {{ range .Assignees }} diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index cedf29e2e9c66..cebcb24e63b7c 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -72,6 +72,11 @@ {{svg "octicon-organization" 16}} {{.i18n.Tr "new_org"}} {{end}} + {{ if not .UnitProjectsGlobalDisabled}} + + {{svg "octicon-plus" 16}} {{ .i18n.Tr "new_project" }} + + {{ end }} diff --git a/templates/repo/header.tmpl b/templates/repo/header.tmpl index 4daaa201d072c..7d69b679447ea 100644 --- a/templates/repo/header.tmpl +++ b/templates/repo/header.tmpl @@ -115,6 +115,15 @@ {{end}} + {{ if and (not .UnitProjectsGlobalDisabled) (.Permission.CanRead $.UnitTypeProjects)}} + + {{svg "octicon-project" 16}} {{.i18n.Tr "repo.project_board"}} + + {{.Repository.NumOpenProjects}} + + + {{ end }} + {{if and (.Permission.CanRead $.UnitTypeReleases) (not .IsEmptyRepo) }} {{svg "octicon-tag" 16}} {{.i18n.Tr "repo.releases"}} {{.NumReleases}} diff --git a/templates/repo/issue/new_form.tmpl b/templates/repo/issue/new_form.tmpl index a42a4d9ad53ec..14e487becd5ae 100644 --- a/templates/repo/issue/new_form.tmpl +++ b/templates/repo/issue/new_form.tmpl @@ -59,7 +59,7 @@ {{svg "octicon-check" 16}} {{.Name | RenderEmoji}} {{if .Description }}
{{.Description | RenderEmoji}}{{end}}
{{end}} - +
{{range .OrgLabels}} {{svg "octicon-check" 16}} {{.Name | RenderEmoji}} @@ -136,6 +136,64 @@ + {{if .IsProjectsEnabled}} +
+ + +
+
+ {{.i18n.Tr "repo.issues.new.no_projects"}} +
+ {{if .Project}} + {{.Project.Title}} + {{end}} +
+
+ {{end}} +
@@ -179,4 +237,3 @@ {{if .PageIsComparePull}} {{end}} - diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl index e3c7df6745ba9..8893a5eaf93b5 100644 --- a/templates/repo/issue/view_content/comments.tmpl +++ b/templates/repo/issue/view_content/comments.tmpl @@ -7,7 +7,9 @@ 13 = STOP_TRACKING, 14 = ADD_TIME_MANUAL, 16 = ADDED_DEADLINE, 17 = MODIFIED_DEADLINE, 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 --> + 26 = DELETE_TIME_MANUAL, 27 = REVIEW_REQUEST, 28 = MERGE_PULL_REQUEST, + 29 = PROJECT_CHANGED, 30 = Project_Board_CHANGED --> + {{if eq .Type 0}}
{{if .OriginalAuthor }} @@ -594,5 +596,26 @@ {{end}}
+ {{else if eq .Type 29}} + {{if not $.UnitProjectsGlobalDisabled}} +
+ {{svg "octicon-project" 16}} + + + + + {{.Poster.GetDisplayName}} + {{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}} + +
+ {{end}} {{end}} {{end}} diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl index 0f34231b17389..8a270eb10df45 100644 --- a/templates/repo/issue/view_content/sidebar.tmpl +++ b/templates/repo/issue/view_content/sidebar.tmpl @@ -28,7 +28,7 @@ {{range $.PullReviewers}} {{if eq .ReviewerID $ReviewerID }} - {{$notReviewed = false }} + {{$notReviewed = false }} {{if eq .Type 4 }} {{$checked = true}} {{if or (eq $ReviewerID $.SignedUserID) $.Permission.IsAdmin}} @@ -194,6 +194,49 @@ + {{if .IsProjectsEnabled}} +
+ + + +
+ {{.i18n.Tr "repo.issues.new.no_projects"}} +
+ {{if .Issue.ProjectID}} + {{.Issue.Project.Title}} + {{end}} +
+
+ {{end}} +
diff --git a/templates/repo/projects/list.tmpl b/templates/repo/projects/list.tmpl new file mode 100644 index 0000000000000..a7cff0db3dff2 --- /dev/null +++ b/templates/repo/projects/list.tmpl @@ -0,0 +1,99 @@ +{{template "base/head" .}} +
+ {{template "repo/header" .}} +
+ +
+ {{template "base/alert" .}} + + + +
+ {{range .Projects}} +
  • + {{svg "octicon-project" 16}} {{.Title}} +
    + {{ $closedDate:= TimeSinceUnix .ClosedDateUnix $.Lang }} + {{if .IsClosed }} + {{svg "octicon-clock" 16}} {{$.i18n.Tr "repo.milestones.closed" $closedDate|Str2html}} + {{end}} + + {{svg "octicon-issue-opened" 16}} {{$.i18n.Tr "repo.issues.open_tab" .NumOpenIssues}} + {{svg "octicon-issue-closed" 16}} {{$.i18n.Tr "repo.issues.close_tab" .NumClosedIssues}} + +
    + {{if and (or $.CanWriteIssues $.CanWritePulls) (not $.Repository.IsArchived)}} + + {{end}} + {{if .Description}} +
    + {{.RenderedContent|Str2html}} +
    + {{end}} +
  • + {{end}} + + {{template "base/paginate" .}} +
    +
    +
    + +{{if or .CanWriteIssues .CanWritePulls}} + +{{end}} +{{template "base/footer" .}} diff --git a/templates/repo/projects/new.tmpl b/templates/repo/projects/new.tmpl new file mode 100644 index 0000000000000..2da722bf9eda8 --- /dev/null +++ b/templates/repo/projects/new.tmpl @@ -0,0 +1,70 @@ +{{template "base/head" .}} +
    + {{template "repo/header" .}} +
    + +
    +

    + {{if .PageIsEditProjects}} + {{.i18n.Tr "repo.projects.edit"}} +
    {{.i18n.Tr "repo.projects.edit_subheader"}}
    + {{else}} + {{.i18n.Tr "repo.projects.new"}} +
    {{.i18n.Tr "repo.projects.new_subheader"}}
    + {{end}} +

    + {{template "base/alert" .}} +
    + {{.CsrfTokenHtml}} +
    +
    + + +
    +
    + + +
    + + {{if not .PageIsEditProjects}} + + + {{end}} +
    +
    +
    +
    + {{if .PageIsEditProjects}} + + {{.i18n.Tr "repo.milestones.cancel"}} + + + {{else}} + + {{end}} +
    +
    + +
    +
    +
    +{{template "base/footer" .}} diff --git a/templates/repo/projects/view.tmpl b/templates/repo/projects/view.tmpl new file mode 100644 index 0000000000000..842b27760c138 --- /dev/null +++ b/templates/repo/projects/view.tmpl @@ -0,0 +1,151 @@ +{{template "base/head" .}} +
    + {{template "repo/header" .}} +
    +
    +
    + {{template "repo/issue/navbar" .}} +
    +
    + {{template "repo/issue/search" .}} +
    +
    + {{if .PageIsProjects}} + {{.i18n.Tr "new_project_board"}} + {{end}} + + +
    +
    +
    +
    + +
    + +
    + {{ range $board := .Boards }} + +
    +
    {{.Title}}
    + + {{ if $.IsSigned }} + {{ if not (eq .ID 0) }} + + {{ end }} + {{ end }} +
    + +
    + + {{ range .Issues }} + + +
    +
    +
    + + {{if .IsPull}}{{svg "octicon-git-merge" 16}} + {{else if .IsClosed}}{{svg "octicon-issue-closed" 16}} + {{else}}{{svg "octicon-issue-opened" 16}} + {{end}} + + {{ .Title }} +
    +
    + {{ if .MilestoneID }} + + {{svg "octicon-milestone" 16}} {{ .Milestone.Name }} + + {{ end }} +
    +
    +
    + {{ range .Labels }} + {{.Name}} + {{ end }} +
    +
    + + + {{ end }} +
    +
    + {{ end }} +
    + +
    + +
    + +{{template "base/footer" .}} diff --git a/templates/repo/settings/options.tmpl b/templates/repo/settings/options.tmpl index c674fcf7f962e..f21f5634a46c6 100644 --- a/templates/repo/settings/options.tmpl +++ b/templates/repo/settings/options.tmpl @@ -269,6 +269,21 @@ +
    + + {{$isProjectsEnabled := .Repository.UnitEnabled $.UnitTypeProjects}} +
    + + {{if .UnitTypeProjects.UnitGlobalDisabled}} +
    + {{else}} +
    + {{end}} + + +
    +
    + {{if .Repository.CanEnablePulls}}
    {{$pullRequestEnabled := .Repository.UnitEnabled $.UnitTypePullRequests}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 01ad43a904568..9087077cb1fed 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -12116,6 +12116,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", @@ -13863,6 +13868,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 f3cac7befb9a0..7080a2cb472fb 100644 --- a/templates/user/profile.tmpl +++ b/templates/user/profile.tmpl @@ -83,7 +83,7 @@