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 ( + handleClose(tag)} + > + {tag.name} + + ); + })} + + {inputVisible && ( + + )} + + {!inputVisible && tags.length < maxTags && ( + + New Tag + + )} + + ); +}; + +// Helper function for text color contrast +function getContrastColor(hexColor) { + // Convert hex to RGB + const r = parseInt(hexColor.slice(1, 3), 16); + const g = parseInt(hexColor.slice(3, 5), 16); + const b = parseInt(hexColor.slice(5, 7), 16); + + // Calculate brightness + const brightness = (r * 299 + g * 587 + b * 114) / 1000; + + // Return black or white depending on brightness + return brightness > 128 ? '#000000' : '#FFFFFF'; +} + +export default TagInput; diff --git a/ui/src/pages/projects/ProjectForm.jsx b/ui/src/pages/projects/ProjectForm.jsx new file mode 100644 index 00000000000..5f4ef8fe853 --- /dev/null +++ b/ui/src/pages/projects/ProjectForm.jsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { Form, Input, Button } from 'antd'; +import TagInput from '../../components/TagInput'; + +const ProjectForm = ({ project, onSubmit, ...props }) => { + const [form] = Form.useForm(); + + const handleSubmit = (values) => { + onSubmit(values); + }; + + return ( +
+ + + + + + + + + + + + + + + +
+ ); +}; + +export default ProjectForm; diff --git a/ui/src/pages/projects/Projects.jsx b/ui/src/pages/projects/Projects.jsx new file mode 100644 index 00000000000..ca2beab20c0 --- /dev/null +++ b/ui/src/pages/projects/Projects.jsx @@ -0,0 +1,101 @@ +import React, { useState, useEffect } from 'react'; +import axios from 'axios'; +import { Tag, Select } from 'antd'; +const { Option } = Select; + +const Projects = () => { + const [projects, setProjects] = useState([]); + const [tags, setTags] = useState([]); + const [selectedTags, setSelectedTags] = useState([]); + + useEffect(() => { + // Fetch all tags + axios.get('/api/tags') + .then(response => { + if (response.data.success) { + setTags(response.data.tags); + } + }) + .catch(error => console.error('Error fetching tags:', error)); + }, []); + + useEffect(() => { + // Fetch projects with tag filtering if needed + const tagsQuery = selectedTags.length ? `tags=${selectedTags.join(',')}` : ''; + axios.get(`/api/projects?${tagsQuery}`) + .then(response => { + if (response.data.success) { + setProjects(response.data.projects); + } + }) + .catch(error => console.error('Error fetching projects:', error)); + }, [selectedTags]); + + const handleTagFilterChange = (values) => { + setSelectedTags(values); + }; + + // Render project tag labels + const renderTags = (projectTags) => { + return ( +
+ {projectTags.map(tag => ( + + {tag.name} + + ))} +
+ ); + }; + + return ( +
+
+

Projects

+ +
+ Filter by tags: + +
+
+ +
+ {projects.map(project => ( +
+

{project.name}

+

{project.description}

+ + {/* Add tag display */} + {project.tags && project.tags.length > 0 && ( +
+ {renderTags(project.tags)} +
+ )} +
+ ))} +
+
+ ); +}; + +// Helper function for text color contrast - same as in TagInput +function getContrastColor(hexColor) { + // ...existing function... +} + +export default Projects;