feat[syncer]: implement data source Jira (#18558)

### Summary

As title
This commit is contained in:
Haruko386
2026-08-20 19:11:41 +08:00
committed by GitHub
parent 1e46cef2d5
commit 2be70f34a9
3 changed files with 1377 additions and 0 deletions

View File

@@ -38,6 +38,7 @@ func RegisterBuiltIns(registry *Registry) {
registerBuiltIn(registry, "r2", NewR2Connector)
registerBuiltIn(registry, "dingtalk_ai_table", NewDingTalkAITableConnector)
registerBuiltIn(registry, "imap", NewIMAPConnector)
registerBuiltIn(registry, "jira", NewJiraConnector)
registerBuiltIn(registry, "outlook", NewOutlookConnector)
registerBuiltIn(registry, "notion", NewNotionConnector)
registerBuiltIn(registry, "rest_api", NewRestAPIConnector)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,325 @@
package connector
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestJiraConnectorOpenSyncCloud(t *testing.T) {
server := jiraFixtureServer(t)
defer server.Close()
connector, err := NewJiraConnector(map[string]any{
"base_url": server.URL,
"project_key": "RAG",
"batch_size": 10,
"include_comments": true,
"include_attachments": true,
"timezone_offset": 0,
"credentials": map[string]any{
"jira_user_email": "alice@example.com",
"jira_api_token": "token",
"rest_api_version": "3",
},
})
if err != nil {
t.Fatalf("NewJiraConnector failed: %v", err)
}
start := mustTime(t, "2026-01-02T12:00:00Z")
end := mustTime(t, "2026-01-04T00:00:00Z")
session, err := connector.OpenSync(context.Background(), SyncRequest{WindowStart: &start, WindowEnd: end})
if err != nil {
t.Fatalf("OpenSync failed: %v", err)
}
batch, err := session.NextBatch(context.Background())
if err != nil {
t.Fatalf("NextBatch failed: %v", err)
}
if len(batch.Documents) != 2 {
t.Fatalf("documents len = %d, want 2", len(batch.Documents))
}
issue := batch.Documents[0]
if issue.SourceID != server.URL+"/browse/RAG-7" {
t.Fatalf("issue source id = %q", issue.SourceID)
}
if issue.Extension != ".md" {
t.Fatalf("issue extension = %q", issue.Extension)
}
body := string(issue.Blob)
for _, want := range []string{"key: RAG-7", "## Description", "Implement Jira sync", "## Comments", "Looks good"} {
if !strings.Contains(body, want) {
t.Fatalf("issue body missing %q:\n%s", want, body)
}
}
attachment := batch.Documents[1]
if attachment.SourceID != "RAG-7::attachment::10001" {
t.Fatalf("attachment source id = %q", attachment.SourceID)
}
if string(attachment.Blob) != "attachment body" {
t.Fatalf("attachment body = %q", attachment.Blob)
}
if _, err = session.NextBatch(context.Background()); !errors.Is(err, io.EOF) {
t.Fatalf("NextBatch EOF = %v", err)
}
}
func TestJiraConnectorOpenPrune(t *testing.T) {
server := jiraFixtureServer(t)
defer server.Close()
connector, err := NewJiraConnector(map[string]any{
"base_url": server.URL,
"project_key": "RAG",
"timezone_offset": 0,
"credentials": map[string]any{
"jira_api_token": "token",
"rest_api_version": "3",
},
})
if err != nil {
t.Fatalf("NewJiraConnector failed: %v", err)
}
session, err := connector.OpenPrune(context.Background(), PruneRequest{})
if err != nil {
t.Fatalf("OpenPrune failed: %v", err)
}
batch, err := session.NextBatch(context.Background())
if err != nil {
t.Fatalf("NextBatch failed: %v", err)
}
if len(batch.Documents) != 1 || batch.Documents[0].SourceID != server.URL+"/browse/RAG-7" {
t.Fatalf("prune docs = %+v", batch.Documents)
}
}
func TestJiraConnectorRegisteredBuiltIn(t *testing.T) {
registry := NewRegistry()
RegisterBuiltIns(registry)
connector, err := registry.OpenFromConfig("jira", map[string]any{
"base_url": "https://jira.example.com",
"project_key": "RAG",
"credentials": map[string]any{
"jira_api_token": "token",
},
})
if err != nil {
t.Fatalf("OpenFromConfig failed: %v", err)
}
if _, ok := connector.(*JiraConnector); !ok {
t.Fatalf("connector type = %T, want *JiraConnector", connector)
}
}
func TestJiraConnectorBuildJQLMovesUserOrderAfterFilters(t *testing.T) {
start := mustTime(t, "2026-01-02T12:00:00Z")
end := mustTime(t, "2026-01-04T00:00:00Z")
connector := &JiraConnector{
jqlQuery: `status = "Done" ORDER BY priority DESC, updated ASC`,
timezoneOffset: 0,
}
got := connector.buildJQL(&start, end)
want := `(status = "Done") AND updated >= "2026-01-02 12:00" AND updated <= "2026-01-04 00:00" ORDER BY priority DESC, updated ASC`
if got != want {
t.Fatalf("buildJQL() = %q, want %q", got, want)
}
}
func TestJiraConnectorBuildJQLKeepsDefaultOrder(t *testing.T) {
connector := &JiraConnector{
jqlQuery: `status = "Done"`,
timezoneOffset: 0,
}
got := connector.buildJQL(nil, time.Time{})
want := `(status = "Done") ORDER BY updated ASC`
if got != want {
t.Fatalf("buildJQL() = %q, want %q", got, want)
}
}
func TestJiraConnectorBuildJQLIgnoresQuotedOrderBy(t *testing.T) {
connector := &JiraConnector{
jqlQuery: `summary ~ "order by updated"`,
timezoneOffset: 0,
}
got := connector.buildJQL(nil, time.Time{})
want := `(summary ~ "order by updated") ORDER BY updated ASC`
if got != want {
t.Fatalf("buildJQL() = %q, want %q", got, want)
}
}
func TestJiraConnectorDownloadRejectsSchemeMismatch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("request should not be sent for mismatched scheme")
}))
defer server.Close()
connector, err := NewJiraConnector(map[string]any{
"base_url": server.URL,
"project_key": "RAG",
"credentials": map[string]any{
"jira_api_token": "token",
},
})
if err != nil {
t.Fatalf("NewJiraConnector failed: %v", err)
}
attachmentURL := "https://" + strings.TrimPrefix(server.URL, "http://") + "/attachment"
if _, err = connector.downloadURL(context.Background(), attachmentURL); err == nil {
t.Fatalf("downloadURL should reject attachment URL scheme mismatch")
}
}
func TestJiraConnectorDownloadRejectsCrossOriginRedirect(t *testing.T) {
destinationHit := false
destination := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
destinationHit = true
}))
defer destination.Close()
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, destination.URL+"/attachment", http.StatusFound)
}))
defer source.Close()
connector, err := NewJiraConnector(map[string]any{
"base_url": source.URL,
"project_key": "RAG",
"credentials": map[string]any{
"jira_api_token": "token",
},
})
if err != nil {
t.Fatalf("NewJiraConnector failed: %v", err)
}
if _, err = connector.downloadURL(context.Background(), source.URL+"/redirect"); err == nil {
t.Fatalf("downloadURL should reject cross-origin redirect")
}
if destinationHit {
t.Fatalf("cross-origin redirect target was requested")
}
}
func TestJiraConnectorDownloadKeepsAuthorizationOnSameOriginRedirect(t *testing.T) {
var server *httptest.Server
mux := http.NewServeMux()
mux.HandleFunc("/redirect", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, server.URL+"/attachment", http.StatusFound)
})
mux.HandleFunc("/attachment", func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer token" {
t.Fatalf("Authorization header = %q, want Bearer token", got)
}
_, _ = w.Write([]byte("attachment body"))
})
server = httptest.NewServer(mux)
defer server.Close()
connector, err := NewJiraConnector(map[string]any{
"base_url": server.URL,
"project_key": "RAG",
"credentials": map[string]any{
"jira_api_token": "token",
},
})
if err != nil {
t.Fatalf("NewJiraConnector failed: %v", err)
}
blob, err := connector.downloadURL(context.Background(), server.URL+"/redirect")
if err != nil {
t.Fatalf("downloadURL failed: %v", err)
}
if string(blob) != "attachment body" {
t.Fatalf("downloaded body = %q", blob)
}
}
func jiraFixtureServer(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/rest/api/3/project/RAG", func(w http.ResponseWriter, r *http.Request) {
jiraWriteJSON(t, w, map[string]any{"key": "RAG"})
})
mux.HandleFunc("/rest/api/3/search/jql", func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("jql"); !strings.Contains(got, `project = "RAG"`) {
t.Errorf("jql = %q", got)
return
}
jiraWriteJSON(t, w, map[string]any{
"issues": []map[string]any{{"id": "10000"}},
})
})
var server *httptest.Server
mux.HandleFunc("/rest/api/3/issue/bulkfetch", func(w http.ResponseWriter, r *http.Request) {
jiraWriteJSON(t, w, map[string]any{"issues": []map[string]any{jiraFixtureIssue(server.URL)}})
})
mux.HandleFunc("/attachment/10001", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("attachment body"))
})
server = httptest.NewServer(mux)
return server
}
func jiraFixtureIssue(baseURL string) map[string]any {
return map[string]any{
"id": "10000",
"key": "RAG-7",
"fields": map[string]any{
"summary": "Implement Jira sync",
"description": "Implement Jira sync in Go",
"updated": "2026-01-03T10:00:00.000+0000",
"created": "2026-01-02T10:00:00.000+0000",
"status": map[string]any{"name": "Open"},
"priority": map[string]any{"name": "High"},
"issuetype": map[string]any{"name": "Task"},
"project": map[string]any{"name": "RAGFlow", "key": "RAG"},
"reporter": map[string]any{"displayName": "Alice", "emailAddress": "alice@example.com"},
"assignee": map[string]any{"displayName": "Bob", "emailAddress": "bob@example.com"},
"labels": []string{"sync"},
"comment": map[string]any{"comments": []map[string]any{{
"author": map[string]any{"displayName": "Carol", "emailAddress": "carol@example.com"},
"created": "2026-01-03T11:00:00.000+0000",
"body": "Looks good",
}}},
"attachment": []map[string]any{{
"id": "10001",
"filename": "note.txt",
"content": baseURL + "/attachment/10001",
"size": 15,
"created": "2026-01-03T12:00:00.000+0000",
}},
},
}
}
func jiraWriteJSON(t *testing.T, w http.ResponseWriter, value any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(value); err != nil {
t.Errorf("Encode JSON failed: %v", err)
}
}
func TestJiraTimeParsesOffset(t *testing.T) {
var value jiraTime
if err := value.UnmarshalJSON([]byte(`"2026-01-03T10:00:00.000+0800"`)); err != nil {
t.Fatalf("UnmarshalJSON failed: %v", err)
}
want := time.Date(2026, 1, 3, 2, 0, 0, 0, time.UTC)
if got := value.Time(); !got.Equal(want) {
t.Fatalf("time = %s, want %s", got, want)
}
}