diff --git a/api/project.go b/api/project.go
new file mode 100644
index 00000000000..e835e4a11f6
--- /dev/null
+++ b/api/project.go
@@ -0,0 +1,64 @@
+package main
+
+import (
+ "strings"
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+ "myapp/models"
+ "myapp/shared"
+)
+
+var db *gorm.DB
+
+// listProjects returns all projects with optional tag filtering
+func listProjects(c *gin.Context) {
+ // Get tag filter params if any
+ tagFilter := c.Query("tags")
+
+ var projects []models.Project
+ query := db.Model(&models.Project{})
+
+ // Apply tag filtering if provided
+ if tagFilter != "" {
+ tags := strings.Split(tagFilter, ",")
+ query = query.Joins("JOIN _devlake_project_tags pt ON pt.project_id = projects.id").
+ Joins("JOIN tags t ON t.id = pt.tag_id").
+ Where("t.name IN ?", tags).
+ Group("projects.id").
+ Having("COUNT(DISTINCT t.name) = ?", len(tags))
+ }
+
+ // Execute the query
+ if err := query.Find(&projects).Error; err != nil {
+ shared.ApiErrorHandler(c, err)
+ return
+ }
+
+ // Load the tags for each project
+ for i := range projects {
+ db.Model(&projects[i]).Association("Tags").Find(&projects[i].Tags)
+ }
+
+ c.JSON(200, projects)
+}
+
+// getProject returns a specific project
+func getProject(c *gin.Context) {
+ var project models.Project
+ if err := db.First(&project, c.Param("id")).Error; err != nil {
+ shared.ApiErrorHandler(c, err)
+ return
+ }
+
+ // Load associated tags
+ db.Model(&project).Association("Tags").Find(&project.Tags)
+
+ c.JSON(200, project)
+}
+
+func main() {
+ r := gin.Default()
+ r.GET("/projects", listProjects)
+ r.GET("/projects/:id", getProject)
+ r.Run()
+}
diff --git a/api/tag.go b/api/tag.go
new file mode 100644
index 00000000000..d5deb6c1154
--- /dev/null
+++ b/api/tag.go
@@ -0,0 +1,245 @@
+package api
+
+import (
+ "net/http"
+
+ "github.com/apache/incubator-devlake/api/shared"
+ "github.com/apache/incubator-devlake/models"
+ "github.com/gin-gonic/gin"
+)
+
+// TagResponse is the API response for a tag
+type TagResponse struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+ Tag models.Tag `json:"tag"`
+}
+
+// TagsResponse is the API response for multiple tags
+type TagsResponse struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+ Tags []models.Tag `json:"tags"`
+}
+
+// TagRequest is the request body for creating/updating tags
+type TagRequest struct {
+ Name string `json:"name" binding:"required"`
+ Description string `json:"description"`
+ Color string `json:"color" default:"#3399FF"`
+}
+
+// RegisterTagsRoutes registers the routes for tag management
+func RegisterTagsRoutes(router *gin.RouterGroup) {
+ // Get all tags
+ router.GET("/tags", listTags)
+
+ // Create a new tag
+ router.POST("/tags", createTag)
+
+ // Get a specific tag
+ router.GET("/tags/:id", getTag)
+
+ // Update a tag
+ router.PATCH("/tags/:id", updateTag)
+
+ // Delete a tag
+ router.DELETE("/tags/:id", deleteTag)
+
+ // Project tag association endpoints
+ router.POST("/projects/:projectId/tags/:tagId", addTagToProject)
+ router.DELETE("/projects/:projectId/tags/:tagId", removeTagFromProject)
+ router.GET("/projects/:projectId/tags", getProjectTags)
+}
+
+// listTags returns all tags
+func listTags(c *gin.Context) {
+ var tags []models.Tag
+ if err := db.Find(&tags).Error; err != nil {
+ shared.ApiErrorHandler(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, TagsResponse{
+ Success: true,
+ Tags: tags,
+ })
+}
+
+// createTag creates a new tag
+func createTag(c *gin.Context) {
+ var req TagRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ shared.ApiErrorHandler(c, err)
+ return
+ }
+
+ tag := models.Tag{
+ Name: req.Name,
+ Description: req.Description,
+ Color: req.Color,
+ }
+
+ if err := db.Create(&tag).Error; err != nil {
+ shared.ApiErrorHandler(c, err)
+ return
+ }
+
+ c.JSON(http.StatusCreated, TagResponse{
+ Success: true,
+ Message: "Tag created successfully",
+ Tag: tag,
+ })
+}
+
+// getTag returns a specific tag by ID
+func getTag(c *gin.Context) {
+ id := c.Param("id")
+ var tag models.Tag
+
+ if err := db.First(&tag, "id = ?", id).Error; err != nil {
+ shared.ApiErrorHandler(c, err)
+ return
+ }
+
+ c.JSON(http.StatusOK, TagResponse{
+ Success: true,
+ Tag: tag,
+ })
+}
+
+// updateTag updates a tag
+func updateTag(c *gin.Context) {
+ id := c.Param("id")
+ var req TagRequest
+
+ if err := c.ShouldBindJSON(&req); err != nil {
+ shared.ApiErrorHandler(c, err)
+ return
+ }
+
+ var tag models.Tag
+ if err := db.First(&tag, "id = ?", id).Error; err != nil {
+ shared.ApiErrorHandler(c, err)
+ return
+ }
+
+ tag.Name = req.Name
+ tag.Description = req.Description
+ tag.Color = req.Color
+
+ if err := db.Save(&tag).Error; err != nil {
+ shared.ApiErrorHandler(c, err)
+ return
+ }
+
+ c.JSON(http.StatusOK, TagResponse{
+ Success: true,
+ Message: "Tag updated successfully",
+ Tag: tag,
+ })
+}
+
+// deleteTag deletes a tag
+func deleteTag(c *gin.Context) {
+ id := c.Param("id")
+
+ // Delete tag associations first
+ if err := db.Delete(&models.ProjectTag{}, "tag_id = ?", id).Error; err != nil {
+ shared.ApiErrorHandler(c, err)
+ return
+ }
+
+ // Delete the tag
+ if err := db.Delete(&models.Tag{}, "id = ?", id).Error; err != nil {
+ shared.ApiErrorHandler(c, err)
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "Tag deleted successfully",
+ })
+}
+
+// addTagToProject associates a tag with a project
+func addTagToProject(c *gin.Context) {
+ projectId := c.Param("projectId")
+ tagId := c.Param("tagId")
+
+ // Check if project exists
+ var project models.Project
+ if err := db.First(&project, "id = ?", projectId).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
+ return
+ }
+
+ // Check if tag exists
+ var tag models.Tag
+ if err := db.First(&tag, "id = ?", tagId).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Tag not found"})
+ return
+ }
+
+ // Create association
+ projectTag := models.ProjectTag{
+ ProjectId: projectId,
+ TagId: tagId,
+ }
+
+ // Check if association already exists
+ var count int64
+ db.Model(&models.ProjectTag{}).Where("project_id = ? AND tag_id = ?", projectId, tagId).Count(&count)
+ if count > 0 {
+ c.JSON(http.StatusConflict, gin.H{"error": "Project already has this tag"})
+ return
+ }
+
+ if err := db.Create(&projectTag).Error; err != nil {
+ shared.ApiErrorHandler(c, err)
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "Tag added to project successfully",
+ })
+}
+
+// removeTagFromProject removes a tag from a project
+func removeTagFromProject(c *gin.Context) {
+ projectId := c.Param("projectId")
+ tagId := c.Param("tagId")
+
+ if err := db.Delete(&models.ProjectTag{}, "project_id = ? AND tag_id = ?", projectId, tagId).Error; err != nil {
+ shared.ApiErrorHandler(c, err)
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "Tag removed from project successfully",
+ })
+}
+
+// getProjectTags gets all tags for a project
+func getProjectTags(c *gin.Context) {
+ projectId := c.Param("projectId")
+
+ // Check if project exists
+ var project models.Project
+ if err := db.First(&project, "id = ?", projectId).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
+ return
+ }
+
+ var tags []models.Tag
+ if err := db.Model(&project).Association("Tags").Find(&tags); err != nil {
+ shared.ApiErrorHandler(c, err)
+ return
+ }
+
+ c.JSON(http.StatusOK, TagsResponse{
+ Success: true,
+ Tags: tags,
+ })
+}
diff --git a/config/migration/migrationscripts/20230901_add_tags.go b/config/migration/migrationscripts/20230901_add_tags.go
new file mode 100644
index 00000000000..262bacd3220
--- /dev/null
+++ b/config/migration/migrationscripts/20230901_add_tags.go
@@ -0,0 +1,52 @@
+package migrationscripts
+
+import (
+ "github.com/apache/incubator-devlake/core/plugin"
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/helpers/migrationhelper"
+)
+
+type addTags struct{}
+
+func (*addTags) Name() string {
+ return "Add tag tables for project tagging"
+}
+
+func (*addTags) Up(baseRes context.BasicRes) errors.Error {
+ db := baseRes.GetDal()
+
+ err := db.AutoMigrate(&Tag{}, &ProjectTag{})
+ if err != nil {
+ return errors.Convert(err)
+ }
+
+ return nil
+}
+
+// Tag model for migration
+type Tag struct {
+ ID string `gorm:"primaryKey;type:varchar(255)"`
+ Name string `gorm:"type:varchar(255);uniqueIndex"`
+ Description string `gorm:"type:varchar(255)"`
+ Color string `gorm:"type:varchar(50)"`
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+func (Tag) TableName() string {
+ return "_devlake_tags"
+}
+
+// ProjectTag model for migration
+type ProjectTag struct {
+ ProjectId string `gorm:"primaryKey;type:varchar(255)"`
+ TagId string `gorm:"primaryKey;type:varchar(255)"`
+}
+
+func (ProjectTag) TableName() string {
+ return "_devlake_project_tags"
+}
+
+func init() {
+ migrationhelper.RegisterMigration(&addTags{})
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 00000000000..145b6f2bfc1
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,86 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+services:
+ mysql:
+ image: mysql:8
+ volumes:
+ - mysql-storage:/var/lib/mysql
+ restart: always
+ ports:
+ - 3306:3306
+ environment:
+ MYSQL_ROOT_PASSWORD: admin
+ MYSQL_DATABASE: lake
+ MYSQL_USER: merico
+ MYSQL_PASSWORD: merico
+ TZ: UTC
+ command: --character-set-server=utf8mb4
+ --collation-server=utf8mb4_bin
+ --skip-log-bin
+
+ grafana:
+ image: devlake.docker.scarf.sh/apache/devlake-dashboard:v1.0.1
+ ports:
+ - 3002:3000
+ volumes:
+ - grafana-storage:/var/lib/grafana
+ environment:
+ GF_SERVER_ROOT_URL: "http://localhost:4000/grafana"
+ GF_USERS_DEFAULT_THEME: "light"
+ MYSQL_URL: mysql:3306
+ MYSQL_DATABASE: lake
+ MYSQL_USER: merico
+ MYSQL_PASSWORD: merico
+ TZ: UTC
+ restart: always
+ depends_on:
+ - mysql
+
+ devlake:
+ image: devlake.docker.scarf.sh/apache/devlake:v1.0.1
+ ports:
+ - 8080:8080
+ restart: always
+ volumes:
+ - devlake-log:/app/logs
+ env_file:
+ - ./.env
+ environment:
+ LOGGING_DIR: /app/logs
+ TZ: UTC
+ depends_on:
+ - mysql
+
+ config-ui:
+ image: devlake.docker.scarf.sh/apache/devlake-config-ui:v1.0.1
+ ports:
+ - 4000:4000
+ env_file:
+ - ./.env
+ environment:
+ DEVLAKE_ENDPOINT: devlake:8080
+ GRAFANA_ENDPOINT: grafana:3000
+ TZ: UTC
+ #ADMIN_USER: devlake
+ #ADMIN_PASS: merico
+ depends_on:
+ - devlake
+
+volumes:
+ mysql-storage:
+ grafana-storage:
+ devlake-log:
diff --git a/env.example b/env.example
index 44604913aa0..25d866ed916 100755
--- a/env.example
+++ b/env.example
@@ -20,15 +20,12 @@
# Lake plugin dir, absolute path or relative path
PLUGIN_DIR=bin/plugins
-REMOTE_PLUGIN_DIR=python/plugins
# Lake Database Connection String
DB_URL=mysql://merico:merico@mysql:3306/lake?charset=utf8mb4&parseTime=True&loc=UTC
E2E_DB_URL=mysql://merico:merico@mysql:3306/lake_test?charset=utf8mb4&parseTime=True&loc=UTC
# Silent Error Warn Info
DB_LOGGING_LEVEL=Error
-# Skip to update progress of subtasks, default is false (#8142)
-SKIP_SUBTASK_PROGRESS=false
# Lake REST API
PORT=8080
@@ -41,19 +38,12 @@ API_TIMEOUT=120s
API_RETRY=3
API_REQUESTS_PER_HOUR=10000
PIPELINE_MAX_PARALLEL=1
-# resume undone pipelines on start
-RESUME_PIPELINES=true
# Debug Info Warn Error
LOGGING_LEVEL=
LOGGING_DIR=./logs
-ENABLE_STACKTRACE=true
+ENABLE_STACKTRACE=false
FORCE_MIGRATION=false
-# Lake TAP API
-TAP_PROPERTIES_DIR=
-
-DISABLED_REMOTE_PLUGINS=
-
##########################
# Sensitive information encryption key
##########################
@@ -70,16 +60,17 @@ ENDPOINT_CIDR_BLACKLIST=
FORBID_REDIRECTION=false
##########################
-# In plugin gitextractor, use go-git to collector repo's data
+# Set SKIP_COMMIT_FILES to 'false' to enable file collection. Any other value or absence of this parameter will skip collection.
##########################
-USE_GO_GIT_IN_GIT_EXTRACTOR=false
-# NOTE that COMMIT_FILES is part of the COMMIT_STAT
-SKIP_COMMIT_STAT=false
-SKIP_COMMIT_FILES=true
+# SKIP_COMMIT_FILES=true
-# Set if response error when requesting /connections/{connection_id}/test should be wrapped or not
##########################
-WRAP_RESPONSE_ERROR=
+# ENABLE_SUBTASKS_BY_DEFAULT: This environment variable is used to enable or disable the execution of subtasks.
+# The format is as follows: plugin_name1:subtask_name1:enabled_value,plugin_name2:subtask_name2:enabled_value,plugin_name3:subtask_name3:enabled_value
+##########################
+# ENABLE_SUBTASKS_BY_DEFAULT="jira:collectIssueChangelogs:true,jira:extractIssueChangelogs:true,jira:convertIssueChangelogs:true,tapd:collectBugChangelogs:true,tapd:extractBugChangelogs:true,tapd:convertBugChangelogs:true,zentao:collectBugRepoCommits:true,zentao:extractBugRepoCommits:true,zentao:convertBugRepoCommits:true,zentao:collectStoryRepoCommits:true,zentao:extractStoryRepoCommits:true,zentao:convertStoryRepoCommits:true,zentao:collectTaskRepoCommits:true,zentao:extractTaskRepoCommits:true,zentao:convertTaskRepoCommits:true"
-# Enable subtasks by default: plugin_name:subtask_name:enabled
-ENABLE_SUBTASKS_BY_DEFAULT="jira:collectIssueChangelogs:true,jira:extractIssueChangelogs:true,jira:convertIssueChangelogs:true,tapd:collectBugChangelogs:true,tapd:extractBugChangelogs:true,tapd:convertBugChangelogs:true,zentao:collectBugRepoCommits:true,zentao:extractBugRepoCommits:true,zentao:convertBugRepoCommits:true,zentao:collectStoryRepoCommits:true,zentao:extractStoryRepoCommits:true,zentao:convertStoryRepoCommits:true,zentao:collectTaskRepoCommits:true,zentao:extractTaskRepoCommits:true,zentao:convertTaskRepoCommits:true"
\ No newline at end of file
+##########################
+# Set JIRA_JQL_AUTO_FULL_REFRESH to 'true' to enable automatic full refresh of the Jira plugin when JQL changes
+##########################
+JIRA_JQL_AUTO_FULL_REFRESH=true
\ No newline at end of file
diff --git a/models/project.go b/models/project.go
new file mode 100644
index 00000000000..42211ec2cc5
--- /dev/null
+++ b/models/project.go
@@ -0,0 +1,19 @@
+package models
+
+import (
+ "gorm.io/gorm"
+)
+
+// Project represents a DevLake project
+type Project struct {
+ gorm.Model
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Tags []Tag `json:"tags" gorm:"many2many:_devlake_project_tags;"`
+}
+
+// Tag represents a tag for a project
+type Tag struct {
+ gorm.Model
+ Name string `json:"name"`
+}
\ No newline at end of file
diff --git a/models/tag.go b/models/tag.go
new file mode 100644
index 00000000000..6771249e4dd
--- /dev/null
+++ b/models/tag.go
@@ -0,0 +1,24 @@
+package models
+
+import (
+ "github.com/apache/incubator-devlake/core/models/common"
+)
+
+// Tag represents a tag that can be assigned to projects
+type Tag struct {
+ common.DynamicMapBase
+ Name string `json:"name" gorm:"type:varchar(255);uniqueIndex"`
+ Description string `json:"description" gorm:"type:varchar(255)"`
+ Color string `json:"color" gorm:"type:varchar(50)"`
+}
+
+// ProjectTag represents the many-to-many relationship between projects and tags
+type ProjectTag struct {
+ ProjectId string `json:"projectId" gorm:"primaryKey;type:varchar(255)"`
+ TagId string `json:"tagId" gorm:"primaryKey;type:varchar(255)"`
+}
+
+// TableName returns the table name for ProjectTag
+func (ProjectTag) TableName() string {
+ return "_devlake_project_tags"
+}
diff --git a/ui/src/components/TagInput.jsx b/ui/src/components/TagInput.jsx
new file mode 100644
index 00000000000..4dd24151ed8
--- /dev/null
+++ b/ui/src/components/TagInput.jsx
@@ -0,0 +1,134 @@
+import React, { useState, useEffect } from 'react';
+import { Tag, Input, Tooltip } from 'antd';
+import { PlusOutlined } from '@ant-design/icons';
+import axios from 'axios';
+
+const TagInput = ({ value = [], onChange, maxTags = 10 }) => {
+ const [tags, setTags] = useState(value);
+ const [inputVisible, setInputVisible] = useState(false);
+ const [inputValue, setInputValue] = useState('');
+ const [allTags, setAllTags] = useState([]);
+ const inputRef = React.useRef(null);
+
+ useEffect(() => {
+ // Fetch all available tags
+ axios.get('/api/tags')
+ .then(response => {
+ if (response.data.success) {
+ setAllTags(response.data.tags);
+ }
+ })
+ .catch(error => console.error('Error fetching tags:', error));
+ }, []);
+
+ useEffect(() => {
+ if (inputVisible) {
+ inputRef.current?.focus();
+ }
+ }, [inputVisible]);
+
+ useEffect(() => {
+ setTags(value);
+ }, [value]);
+
+ const handleClose = (removedTag) => {
+ const newTags = tags.filter(tag => tag.id !== removedTag.id);
+ setTags(newTags);
+ onChange?.(newTags);
+ };
+
+ const showInput = () => {
+ setInputVisible(true);
+ };
+
+ const handleInputChange = (e) => {
+ setInputValue(e.target.value);
+ };
+
+ const handleInputConfirm = () => {
+ if (inputValue && !tags.some(tag => tag.name.toLowerCase() === inputValue.toLowerCase())) {
+ // Check if tag already exists in system
+ const existingTag = allTags.find(t => t.name.toLowerCase() === inputValue.toLowerCase());
+
+ if (existingTag) {
+ // Use existing tag
+ const newTags = [...tags, existingTag];
+ setTags(newTags);
+ onChange?.(newTags);
+ } else {
+ // Create new tag
+ axios.post('/api/tags', {
+ name: inputValue,
+ color: `#${Math.floor(Math.random()*16777215).toString(16)}`
+ })
+ .then(response => {
+ if (response.data.success) {
+ const newTag = response.data.tag;
+ const newTags = [...tags, newTag];
+ setTags(newTags);
+ onChange?.(newTags);
+ // Add to allTags list
+ setAllTags([...allTags, newTag]);
+ }
+ })
+ .catch(error => console.error('Error creating tag:', error));
+ }
+ }
+
+ setInputVisible(false);
+ setInputValue('');
+ };
+
+ return (
+ <>
+ {tags.map(tag => {
+ return (
+
{project.description}
+ + {/* Add tag display */} + {project.tags && project.tags.length > 0 && ( +