Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions cli/copr_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,7 @@ def action_create(self, args):
packit_forge_projects_allowed=args.packit_forge_projects_allowed,
repo_priority=args.repo_priority,
storage=args.storage,
tags=args.tags,
)

owner_part = username.replace('@', "g/")
Expand Down Expand Up @@ -746,6 +747,7 @@ def action_modify_project(self, args):
runtime_dependencies=args.runtime_dependencies,
packit_forge_projects_allowed=args.packit_forge_projects_allowed,
repo_priority=args.repo_priority,
tags=args.tags,
)

@requires_api_auth
Expand Down Expand Up @@ -1306,6 +1308,11 @@ def create_and_modify_common_opts(parser):
"all repositories in the theproject organization). "
"Can be specified multiple times."
))
parser.add_argument(
"--tags", dest="tags", metavar="TAG", action="append", help=(
"Tag to attach to this project, e.g. cli. "
"Can be specified multiple times."
))


def setup_parser():
Expand Down
10 changes: 10 additions & 0 deletions cli/man/copr-cli.1.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ usage: copr-cli create [-h] --chroot CHROOTS [--repo REPOS]
[--persistent]
[--auto-prune {on,off}]
[--isolation {default, nspawn, simple}]
[--tags TAG]
name

--chroot::
Expand All @@ -124,6 +125,9 @@ Chroot to use for this project. Can be specified multiple times, but at least on
--repo::
Repository to add to this project. Can be specified multiple times.

--tags::
Tag to attach to this project, e.g. cli. Can be specified multiple times.

--initial-pkgs::
List of packages to build in this new project. Can be specified multiple times.

Expand Down Expand Up @@ -166,6 +170,7 @@ usage: copr-cli modify [-h] [--repo REPOS]
[--unlisted-on-hp {on,off}]
[--auto-prune {on,off}]
[--isolation {default, nspawn, simple}]
[--tags TAG]
name

Alters only specified project property.
Expand All @@ -179,6 +184,11 @@ When this option is not used, chroots in the project remain unchanged.
Once you specify a chroot, it is going to be enabled in the project, but
current chroots will not be preserved if they are not specified.

--tags::
Tag to attach to this project, e.g. cli. Can be specified multiple times.
When this option is not used, tags on the project remain unchanged; once
specified, it replaces the full set of tags on the project.

--description::
Description of the project.

Expand Down
2 changes: 2 additions & 0 deletions cli/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,7 @@ def test_create_project(config_from_file, project_proxy_add, capsys):
"packit_forge_projects_allowed": None,
"repo_priority": None,
"storage": None,
"tags": None,
}
assert stdout == "New project was successfully created: http://copr/coprs/jdoe/foo/\n"

Expand Down Expand Up @@ -674,6 +675,7 @@ def test_create_multilib_project(config_from_file, project_proxy_add, capsys):
"packit_forge_projects_allowed": None,
"repo_priority": None,
"storage": None,
"tags": None,
}
assert stdout == "New project was successfully created: http://copr/coprs/jdoe/foo/\n"

Expand Down
2 changes: 2 additions & 0 deletions frontend/copr-frontend.spec
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ BuildRequires: python3dist(beautifulsoup4)
BuildRequires: python3dist(copr-common) >= %copr_common_version
BuildRequires: python3dist(email-validator)
BuildRequires: python3dist(python-dateutil)
BuildRequires: python3dist(python-slugify)
BuildRequires: python3dist(decorator)
BuildRequires: python3dist(flask)
BuildRequires: python3dist(templated-dictionary)
Expand Down Expand Up @@ -137,6 +138,7 @@ Requires: python3dist(alembic)
Requires: python3dist(blinker)
Requires: python3dist(copr-common) >= %copr_common_version
Requires: python3dist(python-dateutil)
Requires: python3dist(python-slugify)
Requires: python3dist(email-validator)
Requires: python3dist(flask)
Requires: python3dist(flask-caching)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""
add project tags

Revision ID: fe7f7d55dde3
Create Date: 2026-08-23 12:23:00.181615
"""

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = 'fe7f7d55dde3'
down_revision = 'e31b4af2468c'
branch_labels = None
depends_on = None


def upgrade():
op.create_table(
'project_tag',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=50), nullable=False),
sa.Column('is_default', sa.Boolean(), server_default='0', nullable=False),
sa.Column('created_on', sa.Integer(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name'),
)
op.create_index(op.f('ix_project_tag_name'), 'project_tag', ['name'], unique=True)

op.create_table(
'copr_project_tag',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('copr_id', sa.Integer(), nullable=False),
sa.Column('project_tag_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['copr_id'], ['copr.id'], ),
sa.ForeignKeyConstraint(['project_tag_id'], ['project_tag.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('copr_id', 'project_tag_id',
name='copr_project_tag_copr_id_project_tag_id_uniq'),
)
op.create_index(op.f('ix_copr_project_tag_copr_id'), 'copr_project_tag', ['copr_id'], unique=False)
op.create_index(op.f('ix_copr_project_tag_project_tag_id'), 'copr_project_tag', ['project_tag_id'], unique=False)


def downgrade():
op.drop_table('copr_project_tag')
op.drop_table('project_tag')
102 changes: 101 additions & 1 deletion frontend/coprs_frontend/coprs/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import flask
import wtforms
from slugify import slugify

from flask_wtf.file import FileRequired, FileField, MultipleFileField

Expand All @@ -20,7 +21,8 @@
from coprs import exceptions
from coprs import helpers
from coprs import models
from coprs.logic.coprs_logic import CoprsLogic, MockChrootsLogic

from coprs.logic.coprs_logic import CoprsLogic, MockChrootsLogic, ProjectTagsLogic
from coprs.logic.users_logic import UsersLogic
from coprs.logic.dist_git_logic import DistGitLogic
from coprs.logic.complex_logic import ComplexLogic
Expand Down Expand Up @@ -213,6 +215,89 @@ def __init__(self, label="", validators=None, copr=None, **kwargs):
self.default = [ch for ch in active_names if ch in copr_chroot_names]


class AdditionalProjectTagsField(wtforms.StringField):
"""
Input fields for project tags
"""
def __init__(self, label="", validators=None, copr=None, **kwargs):
"""
Pre-fill the field with `copr`'s existing non-default tag names, if given.
"""
super().__init__(label, validators, **kwargs)
self.label = label or "Additional tags"
self.data = ""
if copr:
custom_names = [t.name for t in copr.project_tags if not t.is_default]
self.default = ", ".join(custom_names)

def process_formdata(self, valuelist):
"""
Join multiple submitted values into comma separated string
"""
self.data = ", ".join(valuelist) if valuelist else ""

def _value(self):
"""
Render the field's current data back into its comma-separated string form.
"""
if self.data:
return ", ".join(self.data)
return ""


class ProjectTagsFilter:
"""
Turn comma separated tag names into deduplicated, slugified tag names.
"""
@staticmethod
def normalize_tag_name(name):
"""
Remove non-ascii characters and slugify the tag name.
"""
encoded_name = name.encode("ascii", "ignore").decode("ascii")
slugified_name = slugify(encoded_name, max_length=50)
return slugified_name

def __call__(self, value):
"""
Split a comma-separated string into cleaned, deduplicated tag names.
"""
if not value:
return []
cleaned = []
for name in value.split(","):
normalized_name = self.normalize_tag_name(name)
if normalized_name and len(normalized_name) >= 3:
cleaned.append(normalized_name)
return list(dict.fromkeys(cleaned))


class DefaultTagsField(MultiCheckboxField):
"""
Checkboxes for default tags
"""
# pylint: disable=too-few-public-methods
def __init__(self, label="", validators=None, copr=None, **kwargs):
"""
Build the default-tag checkbox choices and pre-tick `copr`'s own default tags, if given.
"""
super().__init__(label, validators, **kwargs)
self.label = label or "Default tags"

default_tag_names = [t.name for t in ProjectTagsLogic.get_default_tags()]
self.choices = [(name, name) for name in default_tag_names]
self.default = self.ticked_default_names(copr)

@staticmethod
def ticked_default_names(copr):
"""
Display the default tags that were already selected.
"""
if not copr:
return []
return [t.name for t in copr.project_tags if t.is_default]


class UrlListValidator(object):

def __init__(self, message=None):
Expand Down Expand Up @@ -589,6 +674,13 @@ class CoprForm(BaseForm):

chroots = ChrootsField()

default_tags = DefaultTagsField()

tags = AdditionalProjectTagsField(
"Additional tags",
filters=[ProjectTagsFilter()],
)

description = wtforms.TextAreaField("Description")

instructions = wtforms.TextAreaField("Instructions")
Expand Down Expand Up @@ -806,6 +898,14 @@ class F(CoprForm):
# a list of default chroots based on `copr`
chroots = ChrootsField(copr=copr)

# We are redefining the original `CoprForm` field because we need to
# pre-tick the default tags already set on `copr`
default_tags = DefaultTagsField(copr=copr)

# We are redefining the original `CoprForm` field because we need to
# pre-fill the project's existing non-default tags
tags = AdditionalProjectTagsField(copr=copr, filters=[ProjectTagsFilter()])

@property
def selected_chroots(self):
return self.chroots.data
Expand Down
Loading
Loading