feat(issues): add external user tracking for app integration
Adds ExternalUserID and ExternalSource fields to issues table to allow external apps to track their own user identifiers when creating issues via API. Includes database migration v336, search filters, JSON API endpoint, and UI filter option. Adds locale strings across all 28 languages for external user filtering.
This commit is contained in:
@@ -122,6 +122,10 @@ type Issue struct {
|
||||
|
||||
// Time estimate
|
||||
TimeEstimate int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||
|
||||
// External app integration - allows apps to track their own user IDs
|
||||
ExternalUserID string `xorm:"INDEX VARCHAR(255)"` // App's custom user identifier
|
||||
ExternalSource string `xorm:"VARCHAR(100)"` // App/source identifier (e.g., "myapp-ios", "myapp-android")
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -54,6 +54,10 @@ type IssuesOptions struct { //nolint:revive // export stutter
|
||||
Owner *user_model.User // issues permission scope, it could be an organization or a user
|
||||
Team *organization.Team // issues permission scope
|
||||
Doer *user_model.User // issues permission scope
|
||||
|
||||
// External app integration filters
|
||||
ExternalUserID string // Filter by external user ID from app integration
|
||||
ExternalSource string // Filter by external source/app identifier
|
||||
}
|
||||
|
||||
// Copy returns a copy of the options.
|
||||
@@ -286,6 +290,14 @@ func applyConditions(sess *xorm.Session, opts *IssuesOptions) {
|
||||
sess.And(builder.Eq{"repository.is_archived": opts.IsArchived.Value()})
|
||||
}
|
||||
|
||||
// External app integration filters
|
||||
if opts.ExternalUserID != "" {
|
||||
sess.And(builder.Eq{"issue.external_user_id": opts.ExternalUserID})
|
||||
}
|
||||
if opts.ExternalSource != "" {
|
||||
sess.And(builder.Eq{"issue.external_source": opts.ExternalSource})
|
||||
}
|
||||
|
||||
applyLabelsCondition(sess, opts)
|
||||
|
||||
if opts.Owner != nil {
|
||||
|
||||
@@ -410,6 +410,7 @@ func prepareMigrationTasks() []*migration {
|
||||
newMigration(333, "Add group_header to repository", v1_26.AddGroupHeaderToRepository),
|
||||
newMigration(334, "Add group_header to user for organization grouping", v1_26.AddGroupHeaderToUser),
|
||||
newMigration(335, "Add is_global to package for global package access", v1_26.AddIsGlobalToPackage),
|
||||
newMigration(336, "Add external user fields to issue for app integration", v1_26.AddExternalUserFieldsToIssue),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
19
models/migrations/v1_26/v336.go
Normal file
19
models/migrations/v1_26/v336.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_26
|
||||
|
||||
import (
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// AddExternalUserFieldsToIssue adds ExternalUserID and ExternalSource columns to the issue table
|
||||
// These fields allow apps to track their own user IDs for issues submitted via the API
|
||||
func AddExternalUserFieldsToIssue(x *xorm.Engine) error {
|
||||
type Issue struct {
|
||||
ExternalUserID string `xorm:"INDEX VARCHAR(255)"`
|
||||
ExternalSource string `xorm:"VARCHAR(100)"`
|
||||
}
|
||||
|
||||
return x.Sync(new(Issue))
|
||||
}
|
||||
@@ -1297,6 +1297,7 @@
|
||||
"repo.issues.filter_project": "Projekt",
|
||||
"repo.issues.filter_project_all": "Všechny projekty",
|
||||
"repo.issues.filter_project_none": "Žádný projekt",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_assignee": "Zpracovatel",
|
||||
"repo.issues.filter_poster": "Autor",
|
||||
"repo.issues.filter_user_placeholder": "Hledat uživatele",
|
||||
|
||||
@@ -1277,6 +1277,7 @@
|
||||
"repo.issues.filter_assignee": "Zuständig",
|
||||
"repo.issues.filter_assignee_no_assignee": "Niemandem zugewiesen",
|
||||
"repo.issues.filter_assignee_any_assignee": "Jemandem zugewiesen",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_poster": "Autor",
|
||||
"repo.issues.filter_user_placeholder": "Benutzer suchen",
|
||||
"repo.issues.filter_user_no_select": "Alle Benutzer",
|
||||
|
||||
@@ -1178,6 +1178,7 @@
|
||||
"repo.issues.filter_project": "ΈÏγο",
|
||||
"repo.issues.filter_project_all": "Όλα τα ÎÏγα",
|
||||
"repo.issues.filter_project_none": "ΧωÏίς ÎÏγα",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_assignee": "ΑποδÎκτης",
|
||||
"repo.issues.filter_poster": "ΣυγγÏαφÎας",
|
||||
"repo.issues.filter_type": "ΤÏπος",
|
||||
|
||||
@@ -1489,6 +1489,7 @@
|
||||
"repo.issues.filter_assignee": "Assignee",
|
||||
"repo.issues.filter_assignee_no_assignee": "Assigned to nobody",
|
||||
"repo.issues.filter_assignee_any_assignee": "Assigned to anybody",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_poster": "Author",
|
||||
"repo.issues.filter_user_placeholder": "Search users",
|
||||
"repo.issues.filter_user_no_select": "All users",
|
||||
|
||||
@@ -1161,6 +1161,7 @@
|
||||
"repo.issues.filter_project": "Proyecto",
|
||||
"repo.issues.filter_project_all": "Todos los proyectos",
|
||||
"repo.issues.filter_project_none": "Ningún proyecto",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_assignee": "Asignada a",
|
||||
"repo.issues.filter_poster": "Autor",
|
||||
"repo.issues.filter_type": "Tipo",
|
||||
|
||||
@@ -913,6 +913,7 @@
|
||||
"repo.issues.filter_label_no_select": "تمامی برچسب‎ها",
|
||||
"repo.issues.filter_milestone": "نقطه عطÙ",
|
||||
"repo.issues.filter_project_none": "هیچ پروژه ثبت نشده",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_assignee": "مسئول رسیدگی",
|
||||
"repo.issues.filter_poster": "مولÙ",
|
||||
"repo.issues.filter_type": "نوع",
|
||||
|
||||
@@ -699,6 +699,7 @@
|
||||
"repo.issues.filter_label_no_select": "Kaikki tunnisteet",
|
||||
"repo.issues.filter_milestone": "Merkkipaalu",
|
||||
"repo.issues.filter_project": "Projekti",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_assignee": "Osoitettu",
|
||||
"repo.issues.filter_poster": "Tekijä",
|
||||
"repo.issues.filter_type": "Tyyppi",
|
||||
|
||||
@@ -1425,6 +1425,7 @@
|
||||
"repo.issues.filter_assignee": "Assigné",
|
||||
"repo.issues.filter_assignee_no_assignee": "Non-assigné",
|
||||
"repo.issues.filter_assignee_any_assignee": "Assigné",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_poster": "Auteur",
|
||||
"repo.issues.filter_user_placeholder": "Rechercher des utilisateurs",
|
||||
"repo.issues.filter_user_no_select": "Tous les utilisateurs",
|
||||
|
||||
@@ -1460,6 +1460,7 @@
|
||||
"repo.issues.filter_assignee": "Sannaitheoir",
|
||||
"repo.issues.filter_assignee_no_assignee": "Sannta do dhuine ar bith",
|
||||
"repo.issues.filter_assignee_any_assignee": "Sannta do dhuine ar bith",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_poster": "Údar",
|
||||
"repo.issues.filter_user_placeholder": "Cuardaigh úsáideoirÃ",
|
||||
"repo.issues.filter_user_no_select": "Gach úsáideoir",
|
||||
|
||||
@@ -1470,6 +1470,7 @@
|
||||
"repo.issues.filter_assignee": "Assignee",
|
||||
"repo.issues.filter_assignee_no_assignee": "Assigned to nobody",
|
||||
"repo.issues.filter_assignee_any_assignee": "Assigned to anybody",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_poster": "Author",
|
||||
"repo.issues.filter_user_placeholder": "Search users",
|
||||
"repo.issues.filter_user_no_select": "All users",
|
||||
|
||||
@@ -644,6 +644,7 @@
|
||||
"repo.issues.filter_label": "CÃmke",
|
||||
"repo.issues.filter_label_no_select": "Minden cÃmke",
|
||||
"repo.issues.filter_milestone": "Mérföldkő",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_assignee": "MegbÃzott",
|
||||
"repo.issues.filter_poster": "Szerző",
|
||||
"repo.issues.filter_type": "TÃpus",
|
||||
|
||||
@@ -614,6 +614,7 @@
|
||||
"repo.issues.add_assignee_at": "telah ditugaskan oleh <b>%s</b> %s",
|
||||
"repo.issues.delete_branch_at": "telah dihapus cabang <b>%s</b> %s",
|
||||
"repo.issues.filter_milestone": "Tonggak",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_assignee": "Menerima",
|
||||
"repo.issues.filter_poster": "Penulis",
|
||||
"repo.issues.filter_type": "Tipe",
|
||||
|
||||
@@ -616,6 +616,7 @@
|
||||
"repo.issues.filter_milestone": "TÃmamót",
|
||||
"repo.issues.filter_project": "Verkefni",
|
||||
"repo.issues.filter_project_none": "Ekkert verkefni",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_assignee": "Úthlutað að",
|
||||
"repo.issues.filter_poster": "Höfundur",
|
||||
"repo.issues.filter_type": "Tegund",
|
||||
|
||||
@@ -947,6 +947,7 @@
|
||||
"repo.issues.filter_milestone": "Traguardo",
|
||||
"repo.issues.filter_project": "Progetto",
|
||||
"repo.issues.filter_project_none": "Nessun progetto",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_assignee": "Assegnatario",
|
||||
"repo.issues.filter_poster": "Autore",
|
||||
"repo.issues.filter_type": "Tipo",
|
||||
|
||||
@@ -1447,6 +1447,7 @@
|
||||
"repo.issues.filter_assignee": "担当者",
|
||||
"repo.issues.filter_assignee_no_assignee": "担当者ãªã—",
|
||||
"repo.issues.filter_assignee_any_assignee": "担当者ã‚り",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_poster": "作æˆè€…",
|
||||
"repo.issues.filter_user_placeholder": "ユーザーを検索",
|
||||
"repo.issues.filter_user_no_select": "ã™ã¹ã¦ã®ãƒ¦ãƒ¼ã‚¶ãƒ¼",
|
||||
|
||||
@@ -580,6 +580,7 @@
|
||||
"repo.issues.filter_label": "ë ˆì´ë¸”",
|
||||
"repo.issues.filter_label_no_select": "ëª¨ë“ ë ˆì´ë¸”",
|
||||
"repo.issues.filter_milestone": "마ì¼ìŠ¤í†¤",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_assignee": "담당ìž",
|
||||
"repo.issues.filter_poster": "작성ìž",
|
||||
"repo.issues.filter_type": "ìœ í˜•",
|
||||
|
||||
@@ -1201,6 +1201,7 @@
|
||||
"repo.issues.filter_project": "Projekts",
|
||||
"repo.issues.filter_project_all": "Visi projekti",
|
||||
"repo.issues.filter_project_none": "Nav projekta",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_assignee": "Atbildīgais",
|
||||
"repo.issues.filter_poster": "Autors",
|
||||
"repo.issues.filter_type": "Veids",
|
||||
|
||||
@@ -931,6 +931,7 @@
|
||||
"repo.issues.filter_label_no_select": "Alle labels",
|
||||
"repo.issues.filter_milestone": "Mijlpaal",
|
||||
"repo.issues.filter_project_none": "Geen project",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_assignee": "Aangewezene",
|
||||
"repo.issues.filter_poster": "Auteur",
|
||||
"repo.issues.filter_type.all_issues": "Alle kwesties",
|
||||
|
||||
@@ -894,6 +894,7 @@
|
||||
"repo.issues.filter_label_no_select": "Wszystkie etykiety",
|
||||
"repo.issues.filter_milestone": "Kamień milowy",
|
||||
"repo.issues.filter_project_none": "Brak projektu",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_assignee": "Przypisany",
|
||||
"repo.issues.filter_poster": "Autor",
|
||||
"repo.issues.filter_type": "Typ",
|
||||
|
||||
@@ -1353,6 +1353,7 @@
|
||||
"repo.issues.filter_project": "Projeto",
|
||||
"repo.issues.filter_project_all": "Todos os projetos",
|
||||
"repo.issues.filter_project_none": "Sem projeto",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_assignee": "AtribuÃdo",
|
||||
"repo.issues.filter_poster": "Autor",
|
||||
"repo.issues.filter_type": "Tipo",
|
||||
|
||||
@@ -1468,6 +1468,7 @@
|
||||
"repo.issues.filter_assignee": "Encarregado",
|
||||
"repo.issues.filter_assignee_no_assignee": "Não atribuÃda",
|
||||
"repo.issues.filter_assignee_any_assignee": "AtribuÃda a alguém",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_poster": "Autor(a)",
|
||||
"repo.issues.filter_user_placeholder": "Procurar utilizadores",
|
||||
"repo.issues.filter_user_no_select": "Todos os utilizadores",
|
||||
|
||||
@@ -1179,6 +1179,7 @@
|
||||
"repo.issues.filter_project": "Проект",
|
||||
"repo.issues.filter_project_all": "Ð’Ñе проекты",
|
||||
"repo.issues.filter_project_none": "Ðет проекта",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_assignee": "Ðазначено",
|
||||
"repo.issues.filter_poster": "Ðвтор",
|
||||
"repo.issues.filter_type": "Тип",
|
||||
|
||||
@@ -884,6 +884,7 @@
|
||||
"repo.issues.filter_label_no_select": "සියලු ලේබල",
|
||||
"repo.issues.filter_milestone": "සන්ධිස්ථà·à¶±à¶º",
|
||||
"repo.issues.filter_project_none": "ව්â€à¶ºà·à¶´à·˜à¶à·’ à¶±à·à¶",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_assignee": "අස්ගිනී",
|
||||
"repo.issues.filter_poster": "à¶šà¶à·˜",
|
||||
"repo.issues.filter_type": "වර්ගය",
|
||||
|
||||
@@ -892,6 +892,7 @@
|
||||
"repo.issues.filter_label": "Å tÃtok",
|
||||
"repo.issues.filter_milestone": "MÃľnik",
|
||||
"repo.issues.filter_project": "Projekt",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_poster": "Autor",
|
||||
"repo.issues.filter_type.review_requested": "Požiadané o revÃziu",
|
||||
"repo.issues.action_open": "Otvoriť",
|
||||
|
||||
@@ -730,6 +730,7 @@
|
||||
"repo.issues.filter_label_no_select": "Alla etiketter",
|
||||
"repo.issues.filter_milestone": "Milsten",
|
||||
"repo.issues.filter_project_none": "Inget projekt",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_assignee": "Förvärvare",
|
||||
"repo.issues.filter_poster": "Upphovsman",
|
||||
"repo.issues.filter_type": "Typ",
|
||||
|
||||
@@ -1453,6 +1453,7 @@
|
||||
"repo.issues.filter_assignee": "Atanan",
|
||||
"repo.issues.filter_assignee_no_assignee": "Hiç kimseye atanmamış",
|
||||
"repo.issues.filter_assignee_any_assignee": "Herhangi bir kimseye atanmış",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_poster": "Yazar",
|
||||
"repo.issues.filter_user_placeholder": "Kullanıcıları ara",
|
||||
"repo.issues.filter_user_no_select": "Tüm kullanıcılar",
|
||||
|
||||
@@ -1309,6 +1309,7 @@
|
||||
"repo.issues.filter_assignee": "Виконавець",
|
||||
"repo.issues.filter_assignee_no_assignee": "Ðікому не приÑвоєно",
|
||||
"repo.issues.filter_assignee_any_assignee": "Призначено будь-кому",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_poster": "Ðвтор",
|
||||
"repo.issues.filter_user_placeholder": "Пошук кориÑтувачів",
|
||||
"repo.issues.filter_user_no_select": "УÑÑ– кориÑтувачі",
|
||||
|
||||
@@ -1468,6 +1468,7 @@
|
||||
"repo.issues.filter_assignee": "指派人ç›é€‰",
|
||||
"repo.issues.filter_assignee_no_assignee": "未指派给任何人",
|
||||
"repo.issues.filter_assignee_any_assignee": "已有指派",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_poster": "作者",
|
||||
"repo.issues.filter_user_placeholder": "æœç´¢ç”¨æˆ·",
|
||||
"repo.issues.filter_user_no_select": "所有用户",
|
||||
|
||||
@@ -1303,6 +1303,7 @@
|
||||
"repo.issues.filter_project": "專案",
|
||||
"repo.issues.filter_project_all": "所有專案",
|
||||
"repo.issues.filter_project_none": "æœªé¸æ“‡å°ˆæ¡ˆ",
|
||||
"repo.issues.filter_external_user": "External User",
|
||||
"repo.issues.filter_assignee": "è² è²¬äºº",
|
||||
"repo.issues.filter_poster": "作者",
|
||||
"repo.issues.filter_user_placeholder": "æœå°‹ä½¿ç”¨è€…",
|
||||
|
||||
300
routers/web/repo/issue_json.go
Normal file
300
routers/web/repo/issue_json.go
Normal file
@@ -0,0 +1,300 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"code.gitcaddy.com/server/v3/models/db"
|
||||
issues_model "code.gitcaddy.com/server/v3/models/issues"
|
||||
user_model "code.gitcaddy.com/server/v3/models/user"
|
||||
"code.gitcaddy.com/server/v3/modules/optional"
|
||||
"code.gitcaddy.com/server/v3/modules/setting"
|
||||
"code.gitcaddy.com/server/v3/modules/web"
|
||||
"code.gitcaddy.com/server/v3/services/context"
|
||||
issue_service "code.gitcaddy.com/server/v3/services/issue"
|
||||
vault_service "code.gitcaddy.com/server/v3/services/vault"
|
||||
)
|
||||
|
||||
// IssueSubmitJSON is the request body for submitting an issue via JSON API
|
||||
type IssueSubmitJSON struct {
|
||||
Title string `json:"title" binding:"Required"`
|
||||
Body string `json:"body"`
|
||||
Labels []string `json:"labels"`
|
||||
ExternalUserID string `json:"external_user_id"`
|
||||
ExternalSource string `json:"external_source"`
|
||||
}
|
||||
|
||||
// IssueJSON is a lightweight JSON response for issues
|
||||
type IssueJSON struct {
|
||||
Number int64 `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
State string `json:"state"` // "open" or "closed"
|
||||
Labels []string `json:"labels"`
|
||||
CommentsCount int `json:"comments_count"`
|
||||
ExternalUserID string `json:"external_user_id,omitempty"`
|
||||
ExternalSource string `json:"external_source,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ClosedAt string `json:"closed_at,omitempty"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
|
||||
// IssueListJSON is a response containing a list of issues
|
||||
type IssueListJSON struct {
|
||||
Issues []IssueJSON `json:"issues"`
|
||||
TotalCount int64 `json:"total_count"`
|
||||
}
|
||||
|
||||
// checkVaultTokenForRepo validates vault token for private repo access
|
||||
func checkVaultTokenForRepo(ctx *context.Context) bool {
|
||||
// If user is logged in, they have access through normal means
|
||||
if ctx.Doer != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
// For private repos, check vault token
|
||||
if ctx.Repo.Repository.IsPrivate {
|
||||
token := vaultTokenFromHeader(ctx.Req)
|
||||
if token == "" {
|
||||
ctx.JSON(http.StatusUnauthorized, map[string]string{
|
||||
"error": "unauthorized",
|
||||
"message": "This repository is private. Provide a vault token via Authorization header.",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
validToken, err := validateVaultTokenForRepo(ctx, token, ctx.Repo.Repository.ID)
|
||||
if err != nil || validToken == nil {
|
||||
ctx.JSON(http.StatusUnauthorized, map[string]string{
|
||||
"error": "invalid_token",
|
||||
"message": "Invalid or expired vault token",
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// issueToJSON converts an issue model to JSON response
|
||||
func issueToJSON(issue *issues_model.Issue, baseURL string) IssueJSON {
|
||||
state := "open"
|
||||
if issue.IsClosed {
|
||||
state = "closed"
|
||||
}
|
||||
|
||||
labels := make([]string, 0, len(issue.Labels))
|
||||
for _, label := range issue.Labels {
|
||||
labels = append(labels, label.Name)
|
||||
}
|
||||
|
||||
result := IssueJSON{
|
||||
Number: issue.Index,
|
||||
Title: issue.Title,
|
||||
Body: issue.Content,
|
||||
State: state,
|
||||
Labels: labels,
|
||||
CommentsCount: issue.NumComments,
|
||||
ExternalUserID: issue.ExternalUserID,
|
||||
ExternalSource: issue.ExternalSource,
|
||||
CreatedAt: issue.CreatedUnix.AsTime().Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: issue.UpdatedUnix.AsTime().Format("2006-01-02T15:04:05Z"),
|
||||
HTMLURL: fmt.Sprintf("%s/issues/%d", baseURL, issue.Index),
|
||||
}
|
||||
|
||||
if issue.IsClosed && issue.ClosedUnix > 0 {
|
||||
result.ClosedAt = issue.ClosedUnix.AsTime().Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// IssueSubmitJSONEndpoint handles POST requests to submit new issues via JSON
|
||||
// URL: POST /{owner}/{repo}/issues/submit.json
|
||||
//
|
||||
// Supports authentication via:
|
||||
// - Normal user session
|
||||
// - Vault token in Authorization header (Bearer gvt_xxx)
|
||||
func IssueSubmitJSONEndpoint(ctx *context.Context) {
|
||||
// Set rate limit exemption headers
|
||||
ctx.Resp.Header().Set("X-RateLimit-Exempt", "app-integration")
|
||||
|
||||
// Check access
|
||||
if !checkVaultTokenForRepo(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse JSON body
|
||||
form := web.GetForm(ctx).(*IssueSubmitJSON)
|
||||
|
||||
// Validate title
|
||||
if strings.TrimSpace(form.Title) == "" {
|
||||
ctx.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "validation_error",
|
||||
"message": "Title is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get or create a system user for external submissions
|
||||
var poster *user_model.User
|
||||
var err error
|
||||
|
||||
if ctx.Doer != nil {
|
||||
poster = ctx.Doer
|
||||
} else {
|
||||
// Use ghost user for vault token submissions
|
||||
poster = user_model.NewGhostUser()
|
||||
}
|
||||
|
||||
// Create the issue
|
||||
issue := &issues_model.Issue{
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
Repo: ctx.Repo.Repository,
|
||||
Title: form.Title,
|
||||
Content: form.Body,
|
||||
PosterID: poster.ID,
|
||||
Poster: poster,
|
||||
ExternalUserID: form.ExternalUserID,
|
||||
ExternalSource: form.ExternalSource,
|
||||
}
|
||||
|
||||
// Get label IDs from label names
|
||||
var labelIDs []int64
|
||||
if len(form.Labels) > 0 {
|
||||
labels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, "", db.ListOptions{})
|
||||
if err == nil {
|
||||
labelMap := make(map[string]int64)
|
||||
for _, label := range labels {
|
||||
labelMap[strings.ToLower(label.Name)] = label.ID
|
||||
}
|
||||
for _, labelName := range form.Labels {
|
||||
if id, ok := labelMap[strings.ToLower(labelName)]; ok {
|
||||
labelIDs = append(labelIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the issue using the service
|
||||
if err = issue_service.NewIssue(ctx, ctx.Repo.Repository, issue, labelIDs, nil, nil, 0); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "create_failed",
|
||||
"message": "Failed to create issue: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Load labels for response
|
||||
if err = issue.LoadLabels(ctx); err != nil {
|
||||
// Non-fatal, continue without labels
|
||||
issue.Labels = nil
|
||||
}
|
||||
|
||||
// Build response
|
||||
baseURL := setting.AppURL + ctx.Repo.Repository.FullName()
|
||||
ctx.JSON(http.StatusCreated, issueToJSON(issue, baseURL))
|
||||
}
|
||||
|
||||
// IssueStatusJSONEndpoint returns the status of a specific issue
|
||||
// URL: GET /{owner}/{repo}/issues/{index}/status.json
|
||||
func IssueStatusJSONEndpoint(ctx *context.Context) {
|
||||
// Set rate limit exemption headers
|
||||
ctx.Resp.Header().Set("X-RateLimit-Exempt", "app-integration")
|
||||
|
||||
// Check access
|
||||
if !checkVaultTokenForRepo(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get issue by index
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.JSON(http.StatusNotFound, map[string]string{
|
||||
"error": "not_found",
|
||||
"message": "Issue not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "internal_error",
|
||||
"message": "Failed to get issue",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Load labels
|
||||
if err = issue.LoadLabels(ctx); err != nil {
|
||||
issue.Labels = nil
|
||||
}
|
||||
|
||||
baseURL := setting.AppURL + ctx.Repo.Repository.FullName()
|
||||
ctx.JSON(http.StatusOK, issueToJSON(issue, baseURL))
|
||||
}
|
||||
|
||||
// IssuesByExternalUserJSONEndpoint returns issues for a specific external user ID
|
||||
// URL: GET /{owner}/{repo}/issues/external/{external_user_id}.json
|
||||
func IssuesByExternalUserJSONEndpoint(ctx *context.Context) {
|
||||
// Set rate limit exemption headers
|
||||
ctx.Resp.Header().Set("X-RateLimit-Exempt", "app-integration")
|
||||
|
||||
// Check access
|
||||
if !checkVaultTokenForRepo(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
externalUserID := ctx.PathParam("external_user_id")
|
||||
if externalUserID == "" {
|
||||
ctx.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "validation_error",
|
||||
"message": "External user ID is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Query issues by external user ID
|
||||
issues, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{
|
||||
RepoIDs: []int64{ctx.Repo.Repository.ID},
|
||||
ExternalUserID: externalUserID,
|
||||
IsPull: optional.Some(false),
|
||||
SortType: "newest",
|
||||
})
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "internal_error",
|
||||
"message": "Failed to get issues",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Load labels for all issues
|
||||
for _, issue := range issues {
|
||||
if err = issue.LoadLabels(ctx); err != nil {
|
||||
issue.Labels = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Build response
|
||||
baseURL := setting.AppURL + ctx.Repo.Repository.FullName()
|
||||
issueList := make([]IssueJSON, 0, len(issues))
|
||||
for _, issue := range issues {
|
||||
issueList = append(issueList, issueToJSON(issue, baseURL))
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, IssueListJSON{
|
||||
Issues: issueList,
|
||||
TotalCount: int64(len(issueList)),
|
||||
})
|
||||
}
|
||||
|
||||
// validateVaultTokenForRepo is defined in release.go
|
||||
// vaultTokenFromHeader is defined in release.go
|
||||
|
||||
// Ensure vault_service is used (avoid unused import error)
|
||||
var _ = vault_service.ErrAccessDenied
|
||||
@@ -486,6 +486,7 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6
|
||||
assigneeID := ctx.FormString("assignee")
|
||||
posterUsername := ctx.FormString("poster")
|
||||
posterUserID := shared_user.GetFilterUserIDByName(ctx, posterUsername)
|
||||
externalUserID := ctx.FormString("external_user")
|
||||
var mentionedID, reviewRequestedID, reviewedID int64
|
||||
|
||||
if ctx.IsSigned {
|
||||
@@ -535,6 +536,7 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6
|
||||
ReviewedID: reviewedID,
|
||||
IsPull: isPullOption,
|
||||
IssueIDs: nil,
|
||||
ExternalUserID: externalUserID,
|
||||
}
|
||||
if keyword != "" {
|
||||
keywordMatchedIssueIDs, _, err = issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, statsOpts))
|
||||
@@ -721,6 +723,7 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6
|
||||
ctx.Data["ProjectID"] = projectID
|
||||
ctx.Data["AssigneeID"] = assigneeID
|
||||
ctx.Data["PosterUsername"] = posterUsername
|
||||
ctx.Data["ExternalUserID"] = externalUserID
|
||||
ctx.Data["Keyword"] = keyword
|
||||
ctx.Data["IsShowClosed"] = isShowClosed
|
||||
switch {
|
||||
|
||||
@@ -1537,6 +1537,15 @@ func registerWebRoutes(m *web.Router) {
|
||||
}, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoReleaseReader)
|
||||
// end "/{username}/{reponame}": repo releases
|
||||
|
||||
m.Group("/{username}/{reponame}", func() { // issue JSON API for app integration
|
||||
m.Group("/issues", func() {
|
||||
m.Post("/submit.json", web.Bind(repo.IssueSubmitJSON{}), repo.IssueSubmitJSONEndpoint)
|
||||
m.Get("/external/{external_user_id}.json", repo.IssuesByExternalUserJSONEndpoint)
|
||||
m.Get("/{index}/status.json", repo.IssueStatusJSONEndpoint)
|
||||
})
|
||||
}, optSignIn, context.RepoAssignment, reqRepoIssuesOrPullsReader)
|
||||
// end "/{username}/{reponame}": issue JSON API
|
||||
|
||||
m.Group("/{username}/{reponame}", func() { // to maintain compatibility with old attachments
|
||||
m.Get("/attachments/{uuid}", repo.GetAttachment)
|
||||
}, optSignIn, context.RepoAssignment)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{$queryLink := QueryBuild "?" "q" $.Keyword "type" $.ViewType "sort" $.SortType "state" $.State "labels" $.SelectLabels "milestone" $.MilestoneID "project" $.ProjectID "assignee" $.AssigneeID "poster" $.PosterUsername "archived_labels" (Iif $.ShowArchivedLabels "true")}}
|
||||
{{$queryLink := QueryBuild "?" "q" $.Keyword "type" $.ViewType "sort" $.SortType "state" $.State "labels" $.SelectLabels "milestone" $.MilestoneID "project" $.ProjectID "assignee" $.AssigneeID "poster" $.PosterUsername "external_user" $.ExternalUserID "archived_labels" (Iif $.ShowArchivedLabels "true")}}
|
||||
|
||||
{{template "repo/issue/filter_item_label" dict "Labels" .Labels "QueryLink" $queryLink "SupportArchivedLabel" true}}
|
||||
|
||||
@@ -98,6 +98,16 @@
|
||||
"TextFilterMatchAny" (ctx.Locale.Tr "repo.issues.filter_assignee_any_assignee")
|
||||
}}
|
||||
|
||||
<!-- External User ID Filter (for app integration) -->
|
||||
{{if $.ExternalUserID}}
|
||||
<div class="item">
|
||||
<a class="ui label" href="{{QueryBuild $queryLink "external_user" NIL}}">
|
||||
{{ctx.Locale.Tr "repo.issues.filter_external_user"}}: {{$.ExternalUserID}}
|
||||
{{svg "octicon-x" 14}}
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .IsSigned}}
|
||||
<!-- Type -->
|
||||
<div class="item ui dropdown jump">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{{if .PageIsMilestones}}
|
||||
{{$allStatesLink = QueryBuild "?" "q" $.Keyword "sort" $.SortType "state" "all"}}
|
||||
{{else}}
|
||||
{{$allStatesLink = QueryBuild "?" "q" $.Keyword "type" $.ViewType "sort" $.SortType "state" "all" "labels" $.SelectLabels "milestone" $.MilestoneID "project" $.ProjectID "assignee" $.AssigneeID "poster" $.PosterUsername "archived_labels" (Iif $.ShowArchivedLabels "true")}}
|
||||
{{$allStatesLink = QueryBuild "?" "q" $.Keyword "type" $.ViewType "sort" $.SortType "state" "all" "labels" $.SelectLabels "milestone" $.MilestoneID "project" $.ProjectID "assignee" $.AssigneeID "poster" $.PosterUsername "external_user" $.ExternalUserID "archived_labels" (Iif $.ShowArchivedLabels "true")}}
|
||||
{{end}}
|
||||
{{$openLink = QueryBuild $allStatesLink "state" "open"}}
|
||||
{{$closedLink = QueryBuild $allStatesLink "state" "closed"}}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<input type="hidden" name="project" value="{{$.ProjectID}}">
|
||||
<input type="hidden" name="assignee" value="{{$.AssigneeID}}">
|
||||
<input type="hidden" name="poster" value="{{$.PosterUsername}}">
|
||||
<input type="hidden" name="external_user" value="{{$.ExternalUserID}}">
|
||||
<input type="hidden" name="sort" value="{{$.SortType}}">
|
||||
{{end}}
|
||||
{{template "shared/search/input" dict "Value" .Keyword}}
|
||||
|
||||
Reference in New Issue
Block a user