From 4f5d475c3eebe47d7c0e921799bfdb8337f6a024 Mon Sep 17 00:00:00 2001 From: lyingbug Date: Sat, 26 Sep 2026 14:48:00 +0800 Subject: [PATCH] feat(examples): Jira Cloud plugin with OAuth, dynamic options and a skill (#3730) * feat(examples): Jira Cloud plugin with OAuth, dynamic options and a skill --- examples/plugins/jira/README.md | 59 ++ examples/plugins/jira/adf.go | 213 ++++++++ examples/plugins/jira/client.go | 357 +++++++++++++ examples/plugins/jira/icon.svg | 1 + examples/plugins/jira/jira_test.go | 504 ++++++++++++++++++ examples/plugins/jira/main.go | 451 ++++++++++++++++ examples/plugins/jira/messages.go | 23 + examples/plugins/jira/package.sh | 21 + examples/plugins/jira/plugin.yaml | 42 ++ examples/plugins/jira/schemas/connector.yaml | 99 ++++ examples/plugins/jira/schemas/system.yaml | 17 + .../plugins/jira/skills/jira-triage/SKILL.md | 40 ++ examples/plugins/jira/testdata/adf.json | 160 ++++++ pluginsdk/README.md | 2 + website-docs/03-features/25-plugins.md | 3 +- 15 files changed, 1991 insertions(+), 1 deletion(-) create mode 100644 examples/plugins/jira/README.md create mode 100644 examples/plugins/jira/adf.go create mode 100644 examples/plugins/jira/client.go create mode 100644 examples/plugins/jira/icon.svg create mode 100644 examples/plugins/jira/jira_test.go create mode 100644 examples/plugins/jira/main.go create mode 100644 examples/plugins/jira/messages.go create mode 100755 examples/plugins/jira/package.sh create mode 100644 examples/plugins/jira/plugin.yaml create mode 100644 examples/plugins/jira/schemas/connector.yaml create mode 100644 examples/plugins/jira/schemas/system.yaml create mode 100644 examples/plugins/jira/skills/jira-triage/SKILL.md create mode 100644 examples/plugins/jira/testdata/adf.json diff --git a/examples/plugins/jira/README.md b/examples/plugins/jira/README.md new file mode 100644 index 000000000..9b4ff1d06 --- /dev/null +++ b/examples/plugins/jira/README.md @@ -0,0 +1,59 @@ +# Jira example plugin + +Syncs the issues of selected Jira Cloud projects, with their comments, into a +knowledge base. It also ships a `jira-triage` skill that looks for duplicates, +related work and owners among the synced issues. + +It is the fullest example plugin. It shows: + +- a connector with incremental sync, checkpoints and deletions; +- an OAuth field (`x-oauth`): WeKnora runs Atlassian's consent flow and hands + the plugin a fresh access token at every call; +- dynamic choices (`x-options`): the sites the account can reach and the + site's issue types; +- form messages in the user's language (`call.Locale`); +- a skill in the package. + +## Install + +1. Build the package with `./package.sh` and install it under **System + administration → Plugin management**. +2. To let workspaces sign in with their Atlassian accounts, register an app + first. Without it, workspaces can still use an API token. + 1. In the [Atlassian developer console](https://developer.atlassian.com/console/myapps/), + create an OAuth 2.0 (3LO) app. + 2. Under **Permissions → Jira API**, add `read:jira-work` and + `read:jira-user`. + 3. Under **Authorization**, set the callback URL to + `/api/v1/plugin-oauth/callback`. + 4. In the plugin's details in WeKnora, enter the app's client ID and + secret. +3. Workspace admins switch the plugin on under **Settings → Plugins**, then + add a **Jira** data source to a knowledge base. + +## What is synced + +Each issue becomes one Markdown document: + +- **Title:** ` `. +- **Facts:** type, status, priority, resolution, assignee, reporter, labels, + dates, parent, and a link. +- **Description**, converted from Atlassian Document Format. +- **Comments**, when enabled. + +Attachments are not synced. + +**Incremental syncs** fetch the issues updated since the newest one seen, in +the user's time zone, as JQL requires. + +**Full syncs** also remove the documents of issues that were deleted, moved +out of the selected projects, or no longer match the filters. + +## Test + +```bash +go test ./examples/plugins/jira/ +``` + +The tests run the plugin against a fake Jira Cloud site, both directly with +an API token and through the OAuth gateway. diff --git a/examples/plugins/jira/adf.go b/examples/plugins/jira/adf.go new file mode 100644 index 000000000..fceebbfdc --- /dev/null +++ b/examples/plugins/jira/adf.go @@ -0,0 +1,213 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" +) + +// adfNode is a node of the Atlassian Document Format, the JSON Jira Cloud +// returns rich text in (descriptions, comments). +type adfNode struct { + Type string `json:"type"` + Text string `json:"text"` + Attrs map[string]any `json:"attrs"` + Marks []adfMark `json:"marks"` + Content []adfNode `json:"content"` +} + +type adfMark struct { + Type string `json:"type"` + Attrs map[string]any `json:"attrs"` +} + +// adfToMarkdown renders ADF as Markdown. Unknown nodes keep their text, so +// nothing a new node type holds is lost from search. Plain strings (Jira's +// v2 API, old comments) pass through. +func adfToMarkdown(raw json.RawMessage) string { + if len(raw) == 0 || string(raw) == "null" { + return "" + } + var s string + if json.Unmarshal(raw, &s) == nil { + return strings.TrimSpace(s) + } + var doc adfNode + if err := json.Unmarshal(raw, &doc); err != nil { + return "" + } + var b strings.Builder + blocks(&b, doc.Content, "") + return strings.TrimSpace(b.String()) +} + +// blocks writes block nodes, each line prefixed with indent (list nesting, +// quotes). +func blocks(b *strings.Builder, nodes []adfNode, indent string) { + for _, n := range nodes { + switch n.Type { + case "paragraph": + line(b, indent, inline(n.Content)) + case "heading": + level := 1 + if v, ok := n.Attrs["level"].(float64); ok && v >= 1 && v <= 6 { + level = int(v) + } + line(b, indent, strings.Repeat("#", level)+" "+inline(n.Content)) + case "bulletList", "orderedList": + for i, item := range n.Content { + marker := "- " + if n.Type == "orderedList" { + marker = fmt.Sprintf("%d. ", i+1) + } + listItem(b, item, indent, marker) + } + b.WriteString("\n") + case "codeBlock": + lang, _ := n.Attrs["language"].(string) + b.WriteString(indent + "```" + lang + "\n") + for _, l := range strings.Split(plain(n.Content), "\n") { + b.WriteString(indent + l + "\n") + } + b.WriteString(indent + "```\n\n") + case "blockquote": + blocks(b, n.Content, indent+"> ") + case "rule": + line(b, indent, "---") + case "table": + table(b, n, indent) + case "panel", "expand", "nestedExpand", "layoutSection", "layoutColumn", "bodiedExtension": + if title, _ := n.Attrs["title"].(string); title != "" { + line(b, indent, "**"+title+"**") + } + blocks(b, n.Content, indent) + case "mediaSingle", "mediaGroup", "media": + // Attachments are not synced; say where one was. + line(b, indent, "[attachment]") + default: + if len(n.Content) > 0 { + blocks(b, n.Content, indent) + } else if t := inline([]adfNode{n}); t != "" { + line(b, indent, t) + } + } + } +} + +func line(b *strings.Builder, indent, text string) { + b.WriteString(indent + text + "\n\n") +} + +func listItem(b *strings.Builder, item adfNode, indent, marker string) { + first := true + for _, child := range item.Content { + switch child.Type { + case "paragraph": + prefix := indent + strings.Repeat(" ", len(marker)) + if first { + prefix = indent + marker + } + b.WriteString(prefix + inline(child.Content) + "\n") + default: + // Nested lists and other blocks go one level in. + var sub strings.Builder + blocks(&sub, []adfNode{child}, indent+" ") + b.WriteString(strings.TrimRight(sub.String(), "\n") + "\n") + } + first = false + } +} + +func table(b *strings.Builder, n adfNode, indent string) { + for i, row := range n.Content { + cells := make([]string, 0, len(row.Content)) + for _, cell := range row.Content { + var parts []string + for _, p := range cell.Content { + if t := strings.TrimSpace(inline(p.Content)); t != "" { + parts = append(parts, t) + } + } + cells = append(cells, strings.ReplaceAll(strings.Join(parts, " "), "|", "\\|")) + } + b.WriteString(indent + "| " + strings.Join(cells, " | ") + " |\n") + if i == 0 { + b.WriteString(indent + "|" + strings.Repeat(" --- |", len(cells)) + "\n") + } + } + b.WriteString("\n") +} + +// inline renders inline nodes with their marks. +func inline(nodes []adfNode) string { + var b strings.Builder + for _, n := range nodes { + switch n.Type { + case "text": + b.WriteString(marked(n.Text, n.Marks)) + case "hardBreak": + b.WriteString(" \n") + case "mention": + text, _ := n.Attrs["text"].(string) + if !strings.HasPrefix(text, "@") { + text = "@" + text + } + b.WriteString(text) + case "emoji": + text, _ := n.Attrs["text"].(string) + if text == "" { + text, _ = n.Attrs["shortName"].(string) + } + b.WriteString(text) + case "inlineCard", "blockCard": + u, _ := n.Attrs["url"].(string) + b.WriteString(u) + case "status": + text, _ := n.Attrs["text"].(string) + b.WriteString("[" + text + "]") + case "date": + if ts, ok := n.Attrs["timestamp"].(string); ok { + b.WriteString(ts) + } + default: + b.WriteString(inline(n.Content)) + } + } + return b.String() +} + +func marked(text string, marks []adfMark) string { + if strings.TrimSpace(text) == "" { + return text + } + for _, m := range marks { + switch m.Type { + case "strong": + text = "**" + text + "**" + case "em": + text = "*" + text + "*" + case "strike": + text = "~~" + text + "~~" + case "code": + text = "`" + text + "`" + case "link": + if href, _ := m.Attrs["href"].(string); href != "" { + text = "[" + text + "](" + href + ")" + } + } + } + return text +} + +// plain is the text of nodes without formatting (code blocks). +func plain(nodes []adfNode) string { + var b strings.Builder + for _, n := range nodes { + if n.Type == "hardBreak" { + b.WriteString("\n") + } + b.WriteString(n.Text) + b.WriteString(plain(n.Content)) + } + return b.String() +} diff --git a/examples/plugins/jira/client.go b/examples/plugins/jira/client.go new file mode 100644 index 000000000..830b7204d --- /dev/null +++ b/examples/plugins/jira/client.go @@ -0,0 +1,357 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/Tencent/WeKnora/pluginsdk/pluginapi" +) + +// gatewayURL is Atlassian's API gateway, where OAuth tokens are used. Tests +// point it at a fake. +var gatewayURL = "https://api.atlassian.com" + +// httpClient honors HTTPS_PROXY: a host plugin's traffic must go through the +// plugin host's egress proxy. +var httpClient = &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}, +} + +// Credential fields, as the instance schema names them. +type credentials struct { + Auth string `json:"auth"` + Account string `json:"account"` // an OAuth access token by the time it reaches us + Site string `json:"site"` // cloud ID + BaseURL string `json:"base_url"` + Email string `json:"email"` + APIToken string `json:"api_token"` +} + +// client calls one Jira Cloud site's REST API. +type client struct { + api string // REST root: the site, or the gateway's /ex/jira/{cloudId} + siteURL string // for links to issues + auth string // Authorization header +} + +// site is a Jira site an OAuth token can reach. +type site struct { + ID string `json:"id"` + URL string `json:"url"` + Name string `json:"name"` +} + +// accessibleSites lists the sites an OAuth token was granted. +func accessibleSites(ctx context.Context, token string) ([]site, error) { + c := &client{api: gatewayURL, auth: "Bearer " + token} + var sites []site + err := c.do(ctx, http.MethodGet, "/oauth/token/accessible-resources", nil, &sites) + return sites, err +} + +// newClient checks the credentials and connects to the site they name. +// Problems with the form come back as invalid_config on its fields. +func newClient(ctx context.Context, cr credentials, locale string) (*client, error) { + switch cr.Auth { + case "", "oauth": + if cr.Account == "" { + return nil, pluginapi.InvalidConfig(say(locale, msgConnectAccount), + map[string]string{"credentials.account": "required"}) + } + if cr.Site == "" { + return nil, pluginapi.InvalidConfig(say(locale, msgChooseSite), + map[string]string{"credentials.site": "required"}) + } + sites, err := accessibleSites(ctx, cr.Account) + if err != nil { + return nil, err + } + for _, s := range sites { + if s.ID == cr.Site { + return &client{ + api: gatewayURL + "/ex/jira/" + url.PathEscape(s.ID), + siteURL: strings.TrimSuffix(s.URL, "/"), + auth: "Bearer " + cr.Account, + }, nil + } + } + return nil, pluginapi.InvalidConfig(say(locale, msgSiteGone), + map[string]string{"credentials.site": "not granted"}) + case "token": + base := strings.TrimSuffix(strings.TrimSpace(cr.BaseURL), "/") + fields := map[string]string{} + if base == "" { + fields["credentials.base_url"] = "required" + } + if cr.Email == "" { + fields["credentials.email"] = "required" + } + if cr.APIToken == "" { + fields["credentials.api_token"] = "required" + } + if len(fields) > 0 { + return nil, pluginapi.InvalidConfig(say(locale, msgTokenFields), fields) + } + basic := cr.Email + ":" + cr.APIToken + return &client{ + api: base, siteURL: base, + auth: "Basic " + base64.StdEncoding.EncodeToString([]byte(basic)), + }, nil + default: + return nil, pluginapi.InvalidConfig(say(locale, msgUnknownAuth), + map[string]string{"credentials.auth": "enum"}) + } +} + +// do sends one request and decodes a JSON answer. Jira's failures map to +// protocol errors: WeKnora retries unavailable and rate_limited, and shows +// unauthorized as broken credentials. +func (c *client) do(ctx context.Context, method, path string, body, out any) error { + var rd io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return err + } + rd = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, c.api+path, rd) + if err != nil { + return err + } + req.Header.Set("Authorization", c.auth) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := httpClient.Do(req) + if err != nil { + return pluginapi.Errorf(pluginapi.CodeUnavailable, "jira: %v", err) + } + defer func() { _ = resp.Body.Close() }() + data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) + if err != nil { + return pluginapi.Errorf(pluginapi.CodeUnavailable, "jira: %v", err) + } + if resp.StatusCode >= 300 { + return statusError(resp, data) + } + if out == nil { + return nil + } + if err := json.Unmarshal(data, out); err != nil { + return fmt.Errorf("jira %s: decode: %w", path, err) + } + return nil +} + +func statusError(resp *http.Response, body []byte) error { + msg := jiraMessage(body) + switch { + case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden: + return pluginapi.Errorf( + pluginapi.CodeUnauthorized, + "jira refused the credentials (%d): %s", + resp.StatusCode, + msg, + ) + case resp.StatusCode == http.StatusTooManyRequests: + e := pluginapi.Errorf(pluginapi.CodeRateLimited, "jira rate limit: %s", msg) + if s, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && s > 0 { + e.Details = &pluginapi.ErrorDetails{RetryAfter: s} + } + return e + case resp.StatusCode >= 500: + return pluginapi.Errorf(pluginapi.CodeUnavailable, "jira %d: %s", resp.StatusCode, msg) + case resp.StatusCode == http.StatusBadRequest: + // Mostly a JQL filter Jira does not accept. + return pluginapi.InvalidConfig("jira rejected the query: "+msg, + map[string]string{"settings.jql": msg}) + default: + return fmt.Errorf("jira %d: %s", resp.StatusCode, msg) + } +} + +// jiraMessage extracts Jira's error text ({"errorMessages": [...]}). +func jiraMessage(body []byte) string { + var e struct { + ErrorMessages []string `json:"errorMessages"` + Errors map[string]string `json:"errors"` + Message string `json:"message"` + } + if json.Unmarshal(body, &e) == nil { + parts := append([]string{}, e.ErrorMessages...) + for k, v := range e.Errors { + parts = append(parts, k+": "+v) + } + if e.Message != "" { + parts = append(parts, e.Message) + } + if len(parts) > 0 { + return strings.Join(parts, "; ") + } + } + s := strings.TrimSpace(string(body)) + if len(s) > 200 { + s = s[:200] + } + return s +} + +// myself is the signed-in user. Its time zone is the one JQL dates are in. +type myself struct { + AccountID string `json:"accountId"` + DisplayName string `json:"displayName"` + TimeZone string `json:"timeZone"` +} + +func (c *client) myself(ctx context.Context) (*myself, error) { + var me myself + if err := c.do(ctx, http.MethodGet, "/rest/api/3/myself", nil, &me); err != nil { + return nil, err + } + return &me, nil +} + +type project struct { + ID string `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + Style string `json:"style"` +} + +// projects lists every project the user can browse. +func (c *client) projects(ctx context.Context) ([]project, error) { + var out []project + for start := 0; ; { + var page struct { + Values []project `json:"values"` + IsLast bool `json:"isLast"` + } + q := url.Values{"startAt": {strconv.Itoa(start)}, "maxResults": {"50"}, "orderBy": {"name"}} + if err := c.do(ctx, http.MethodGet, "/rest/api/3/project/search?"+q.Encode(), nil, &page); err != nil { + return nil, err + } + out = append(out, page.Values...) + if page.IsLast || len(page.Values) == 0 { + return out, nil + } + start += len(page.Values) + } +} + +type issueType struct { + Name string `json:"name"` + Subtask bool `json:"subtask"` +} + +func (c *client) issueTypes(ctx context.Context) ([]issueType, error) { + var out []issueType + err := c.do(ctx, http.MethodGet, "/rest/api/3/issuetype", nil, &out) + return out, err +} + +// issue is the part of a Jira issue the connector turns into a document. +type issue struct { + ID string `json:"id"` + Key string `json:"key"` + Fields struct { + Summary string `json:"summary"` + Description json.RawMessage `json:"description"` + Status *named `json:"status"` + IssueType *named `json:"issuetype"` + Priority *named `json:"priority"` + Resolution *named `json:"resolution"` + Assignee *user `json:"assignee"` + Reporter *user `json:"reporter"` + Labels []string `json:"labels"` + Created jiraTime `json:"created"` + Updated jiraTime `json:"updated"` + Project *project `json:"project"` + Parent *struct { + Key string `json:"key"` + } `json:"parent"` + Comment *struct { + Comments []comment `json:"comments"` + Total int `json:"total"` + } `json:"comment"` + } `json:"fields"` +} + +type named struct { + Name string `json:"name"` +} + +type user struct { + DisplayName string `json:"displayName"` +} + +type comment struct { + Author *user `json:"author"` + Created jiraTime `json:"created"` + Body json.RawMessage `json:"body"` +} + +// jiraTime parses Jira's timestamps ("2026-09-26T10:04:05.123+0800"). +type jiraTime struct{ time.Time } + +func (t *jiraTime) UnmarshalJSON(b []byte) error { + var s string + if err := json.Unmarshal(b, &s); err != nil || s == "" { + return nil + } + for _, layout := range []string{"2006-01-02T15:04:05.000-0700", time.RFC3339Nano} { + if v, err := time.Parse(layout, s); err == nil { + t.Time = v + return nil + } + } + return nil +} + +var searchFields = []string{ + "summary", "description", "status", "issuetype", "priority", "resolution", "assignee", "reporter", + "labels", "created", "updated", "project", "parent", "comment", +} + +// search runs JQL a page at a time (the enhanced search API, which pages +// with a token). fn sees each page; returning an error stops the search. +func (c *client) search(ctx context.Context, jql string, withComments bool, fn func([]issue) error) error { + fields := searchFields + if !withComments { + fields = fields[:len(fields)-1] + } + token := "" + for { + body := map[string]any{"jql": jql, "maxResults": 50, "fields": fields} + if token != "" { + body["nextPageToken"] = token + } + var page struct { + Issues []issue `json:"issues"` + NextPageToken string `json:"nextPageToken"` + IsLast bool `json:"isLast"` + } + if err := c.do(ctx, http.MethodPost, "/rest/api/3/search/jql", body, &page); err != nil { + return err + } + if err := fn(page.Issues); err != nil { + return err + } + if page.IsLast || page.NextPageToken == "" || len(page.Issues) == 0 { + return nil + } + token = page.NextPageToken + } +} diff --git a/examples/plugins/jira/icon.svg b/examples/plugins/jira/icon.svg new file mode 100644 index 000000000..7d7b29415 --- /dev/null +++ b/examples/plugins/jira/icon.svg @@ -0,0 +1 @@ + diff --git a/examples/plugins/jira/jira_test.go b/examples/plugins/jira/jira_test.go new file mode 100644 index 000000000..700c99843 --- /dev/null +++ b/examples/plugins/jira/jira_test.go @@ -0,0 +1,504 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "go/parser" + "go/token" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "testing" + "time" + + pluginclient "github.com/Tencent/WeKnora/pluginsdk/client" + "github.com/Tencent/WeKnora/pluginsdk/conformance" + "github.com/Tencent/WeKnora/pluginsdk/pluginapi" +) + +const ( + oauthToken = "access-token-1" + cloudID = "cloud-1" + email = "me@example.com" + apiToken = "api-token" +) + +var shanghai, _ = time.LoadLocation("Asia/Shanghai") + +// fakeJira is a Jira Cloud site reachable both directly (API token) and +// through the OAuth gateway (/ex/jira/{cloudId}). +type fakeJira struct { + *httptest.Server + mu sync.Mutex + issues map[string]map[string]any // id → issue JSON + jql []string +} + +func newFakeJira(t *testing.T) *fakeJira { + f := &fakeJira{issues: map[string]map[string]any{}} + mux := http.NewServeMux() + mux.HandleFunc("GET /oauth/token/accessible-resources", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+oauthToken { + http.Error(w, `{"message":"bad token"}`, http.StatusUnauthorized) + return + } + writeJSON(w, []site{{ID: cloudID, URL: f.URL, Name: "Acme"}}) + }) + api := http.NewServeMux() + api.HandleFunc("GET /rest/api/3/myself", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, myself{AccountID: "a1", DisplayName: "Me", TimeZone: "Asia/Shanghai"}) + }) + api.HandleFunc("GET /rest/api/3/project/search", func(w http.ResponseWriter, r *http.Request) { + all := []project{{Key: "ENG", Name: "Engineering"}, {Key: "OPS", Name: "Operations"}} + start, _ := strconv.Atoi(r.URL.Query().Get("startAt")) + // One per page, to exercise paging. + writeJSON(w, map[string]any{"values": all[start : start+1], "isLast": start+1 == len(all)}) + }) + api.HandleFunc("GET /rest/api/3/issuetype", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, []issueType{{Name: "Task"}, {Name: "Bug"}, {Name: "Bug"}, {Name: "Sub-task", Subtask: true}}) + }) + api.HandleFunc("POST /rest/api/3/search/jql", f.search) + mux.Handle("/ex/jira/"+cloudID+"/", http.StripPrefix("/ex/jira/"+cloudID, bearer(api))) + mux.Handle("/rest/", basic(api)) + f.Server = httptest.NewServer(mux) + t.Cleanup(f.Close) + old := gatewayURL + gatewayURL = f.URL + t.Cleanup(func() { gatewayURL = old }) + return f +} + +func bearer(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+oauthToken { + http.Error(w, `{"errorMessages":["token"]}`, http.StatusUnauthorized) + return + } + h.ServeHTTP(w, r) + }) +} + +func basic(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if u, p, ok := r.BasicAuth(); !ok || u != email || p != apiToken { + http.Error(w, `{"errorMessages":["Client must be authenticated"]}`, http.StatusUnauthorized) + return + } + h.ServeHTTP(w, r) + }) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +var ( + projectClause = regexp.MustCompile(`project = "([A-Z]+)"`) + updatedClause = regexp.MustCompile(`updated >= "([0-9/: ]+)"`) +) + +// search understands the clauses the connector writes and pages two +// issues at a time, oldest update first. +func (f *fakeJira) search(w http.ResponseWriter, r *http.Request) { + var in struct { + JQL string `json:"jql"` + NextPageToken string `json:"nextPageToken"` + Fields []string `json:"fields"` + } + _ = json.NewDecoder(r.Body).Decode(&in) + if strings.Contains(in.JQL, "BROKEN") { + http.Error(w, `{"errorMessages":["Error in the JQL Query"]}`, http.StatusBadRequest) + return + } + f.mu.Lock() + defer f.mu.Unlock() + f.jql = append(f.jql, in.JQL) + key := projectClause.FindStringSubmatch(in.JQL)[1] + var since time.Time + if m := updatedClause.FindStringSubmatch(in.JQL); m != nil { + since, _ = time.ParseInLocation("2006/01/02 15:04", m[1], shanghai) + } + var list []map[string]any + for _, is := range f.issues { + fields := is["fields"].(map[string]any) + updated, _ := time.Parse("2006-01-02T15:04:05.000-0700", fields["updated"].(string)) + if strings.HasPrefix(is["key"].(string), key+"-") && !updated.Before(since) { + list = append(list, is) + } + } + sort.Slice(list, func(i, j int) bool { + updated := func(is map[string]any) string { return is["fields"].(map[string]any)["updated"].(string) } + return updated(list[i]) < updated(list[j]) + }) + start, _ := strconv.Atoi(in.NextPageToken) + end := min(start+2, len(list)) + page := map[string]any{"issues": list[start:end], "isLast": end == len(list)} + if end < len(list) { + page["nextPageToken"] = strconv.Itoa(end) + } + writeJSON(w, page) +} + +func (f *fakeJira) put(id, key, summary, updated string) { + f.mu.Lock() + defer f.mu.Unlock() + f.issues[id] = map[string]any{"id": id, "key": key, "fields": map[string]any{ + "summary": summary, + "updated": updated, + "created": "2026-09-01T09:00:00.000+0800", + "status": map[string]any{"name": "In Progress"}, + "description": map[string]any{"type": "doc", "version": 1, "content": []any{ + map[string]any{"type": "paragraph", "content": []any{ + map[string]any{"type": "text", "text": "Steps to "}, + map[string]any{"type": "text", "text": "reproduce", "marks": []any{map[string]any{"type": "strong"}}}, + }}, + }}, + "comment": map[string]any{"total": 3, "comments": []any{map[string]any{ + "author": map[string]any{"displayName": "Ann"}, + "created": "2026-09-02T09:00:00.000+0800", + "body": map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "paragraph", + "content": []any{map[string]any{"type": "text", "text": "Seen on staging"}}, + }, + }, + }, + }}}, + }} +} + +func (f *fakeJira) remove(id string) { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.issues, id) +} + +func (f *fakeJira) lastJQL() string { + f.mu.Lock() + defer f.mu.Unlock() + return f.jql[len(f.jql)-1] +} + +// run is the plugin behind an HTTP server, called like WeKnora calls it. +type run struct { + t *testing.T + client *pluginclient.Client +} + +func newRun(t *testing.T) *run { + srv := httptest.NewServer(newPlugin().Handler()) + t.Cleanup(srv.Close) + return &run{t: t, client: pluginclient.New(srv.URL, nil, nil)} +} + +func tokenInstance(f *fakeJira, settings map[string]any) map[string]any { + return map[string]any{ + "credentials": map[string]any{"auth": "token", "base_url": f.URL + "/", "email": email, "api_token": apiToken}, + "settings": settings, + } +} + +func oauthInstance(site string) map[string]any { + // WeKnora has already swapped the "oauth:" reference for a token. + return map[string]any{ + "credentials": map[string]any{"auth": "oauth", "account": oauthToken, "site": site}, + "settings": map[string]any{}, + } +} + +type fetched struct { + items []pluginapi.FetchedItem + checkpoints int + cursor *pluginapi.Cursor +} + +func (r *run) fetch(instance map[string]any, mode string, cursor *pluginapi.Cursor, projects ...string) fetched { + r.t.Helper() + var out fetched + end, err := r.client.Stream(context.Background(), pluginapi.ConnectorFetchPath("jira"), + pluginapi.Envelope{Config: pluginapi.Config{Instance: instance}}, + pluginapi.FetchInput{Mode: mode, Cursor: cursor, ResourceIDs: projects}, + func(ev pluginapi.Event) error { + switch ev.Type { + case pluginapi.EventItem: + var it pluginapi.FetchedItem + if err := json.Unmarshal(ev.Data, &it); err != nil { + return err + } + out.items = append(out.items, it) + case pluginapi.EventCheckpoint: + out.checkpoints++ + } + return nil + }) + if err != nil { + r.t.Fatalf("fetch: %v", err) + } + out.cursor = &pluginapi.Cursor{} + if err := json.Unmarshal(end, out.cursor); err != nil { + r.t.Fatal(err) + } + return out +} + +func (r *run) call(path string, instance map[string]any, input, out any) error { + return r.client.Call(context.Background(), path, + pluginapi.Envelope{Config: pluginapi.Config{Instance: instance}}, input, out) +} + +func summary(items []pluginapi.FetchedItem) []string { + var out []string + for _, it := range items { + if it.IsDeleted { + out = append(out, "-"+it.ExternalID) + } else { + out = append(out, it.ExternalID+" "+it.Title) + } + } + return out +} + +func TestSyncIsIncrementalAndReportsDeletions(t *testing.T) { + f := newFakeJira(t) + f.put("101", "ENG-1", "Login fails", "2026-09-20T10:00:00.000+0800") + f.put("102", "ENG-2", "Slow search", "2026-09-21T10:00:30.000+0800") + f.put("103", "ENG-3", "Crash on save", "2026-09-22T10:00:00.000+0800") + f.put("201", "OPS-1", "Not selected", "2026-09-22T10:00:00.000+0800") + r := newRun(t) + inst := tokenInstance(f, map[string]any{"issue_types": []string{"Bug", `Odd "type"`}, "jql": "labels = x"}) + + full := r.fetch(inst, pluginapi.FetchFull, nil, "ENG") + if got := summary(full.items); strings.Join( + got, + "|", + ) != "101 ENG-1 Login fails|102 ENG-2 Slow search|103 ENG-3 Crash on save" { + t.Fatalf("full sync = %v", got) + } + if full.checkpoints != 2 { + t.Fatalf("a checkpoint per page, got %d", full.checkpoints) + } + wantJQL := `project = "ENG" AND issuetype in ("Bug", "Odd \"type\"") AND (labels = x) ORDER BY updated ASC` + if jql := f.lastJQL(); jql != wantJQL { + t.Fatalf("jql = %s", jql) + } + doc := string(full.items[0].Content) + for _, want := range []string{ + "# ENG-1: Login fails", "- **Status:** In Progress", "- **Link:** " + f.URL + "/browse/ENG-1", + "## Description\n\nSteps to **reproduce**", "### Ann · 2026-09-02 09:00 +0800\n\nSeen on staging", + "_2 more comments are not included._", + } { + if !strings.Contains(doc, want) { + t.Errorf("document lacks %q:\n%s", want, doc) + } + } + if it := full.items[0]; it.SourceResourceID != "ENG" || it.Metadata["jira_key"] != "ENG-1" || + it.FileName != "ENG-1 Login fails.md" || it.UpdatedAt == nil { + t.Fatalf("item = %+v", it) + } + + // Incremental: from the newest update seen, in the user's time zone. + f.put("102", "ENG-2", "Slow search (again)", "2026-09-25T08:00:00.000+0800") + inc := r.fetch(inst, pluginapi.FetchIncremental, full.cursor, "ENG") + if !strings.Contains(f.lastJQL(), `updated >= "2026/09/22 10:00"`) { + t.Fatalf("incremental jql = %s", f.lastJQL()) + } + if got := summary(inc.items); strings.Join(got, "|") != "103 ENG-3 Crash on save|102 ENG-2 Slow search (again)" { + t.Fatalf("incremental = %v", got) + } + + // A full sync reports what disappeared, once. + f.remove("101") + again := r.fetch(inst, pluginapi.FetchFull, inc.cursor, "ENG") + if got := summary(again.items); got[len(got)-1] != "-101" || len(got) != 3 { + t.Fatalf("full sync after a deletion = %v", got) + } + once := r.fetch(inst, pluginapi.FetchFull, again.cursor, "ENG") + for _, it := range once.items { + if it.IsDeleted { + t.Fatalf("deletion reported twice: %v", summary(once.items)) + } + } +} + +func TestOAuthAccountsReachTheirSites(t *testing.T) { + f := newFakeJira(t) + f.put("101", "ENG-1", "Login fails", "2026-09-20T10:00:00.000+0800") + r := newRun(t) + + var sites pluginapi.OptionsOutput + err := r.call(pluginapi.OptionsPath("sites"), oauthInstance(""), pluginapi.OptionsInput{Field: "site"}, &sites) + if err != nil { + t.Fatal(err) + } + if len(sites.Options) != 1 || sites.Options[0].Value != cloudID || sites.Options[0].Label != "Acme" { + t.Fatalf("sites = %+v", sites) + } + var types pluginapi.OptionsOutput + err = r.call(pluginapi.OptionsPath("issue_types"), oauthInstance(cloudID), pluginapi.OptionsInput{}, &types) + if err != nil { + t.Fatal(err) + } + if fmt.Sprint(types.Options) != "[{Bug } {Sub-task } {Task }]" { + t.Fatalf("issue types = %v", types.Options) + } + var res pluginapi.ListResourcesOutput + listPath := pluginapi.ConnectorListResourcesPath("jira") + err = r.call(listPath, oauthInstance(cloudID), pluginapi.ListResourcesInput{}, &res) + if err != nil { + t.Fatal(err) + } + if len(res.Resources) != 2 || res.Resources[1].ExternalID != "OPS" || res.Resources[0].URL != f.URL+"/browse/ENG" { + t.Fatalf("projects = %+v", res.Resources) + } + got := r.fetch(oauthInstance(cloudID), pluginapi.FetchFull, nil, "ENG") + if len(got.items) != 1 || got.items[0].URL != f.URL+"/browse/ENG-1" { + t.Fatalf("oauth sync = %v", summary(got.items)) + } +} + +func TestProblemsReachTheForm(t *testing.T) { + f := newFakeJira(t) + r := newRun(t) + cases := []struct { + name string + instance map[string]any + code pluginapi.ErrorCode + }{ + {"no account", map[string]any{"credentials": map[string]any{"auth": "oauth"}}, pluginapi.CodeInvalidConfig}, + {"site not granted", oauthInstance("cloud-2"), pluginapi.CodeInvalidConfig}, + { + "expired token", + map[string]any{"credentials": map[string]any{"auth": "oauth", "account": "old", "site": cloudID}}, + pluginapi.CodeUnauthorized, + }, + { + "missing token fields", + map[string]any{"credentials": map[string]any{"auth": "token"}}, + pluginapi.CodeInvalidConfig, + }, + {"wrong api token", map[string]any{"credentials": map[string]any{ + "auth": "token", "base_url": f.URL, "email": email, "api_token": "nope", + }}, pluginapi.CodeUnauthorized}, + } + for _, tc := range cases { + err := r.call(pluginapi.ConnectorValidatePath("jira"), tc.instance, struct{}{}, nil) + if pe, ok := pluginapi.AsError(err); !ok || pe.Code != tc.code { + t.Errorf("%s: %v", tc.name, err) + } + } + // Messages about the form speak the user's language. + err := r.client.Call(context.Background(), pluginapi.OptionsPath("sites"), pluginapi.Envelope{ + Context: pluginapi.Context{Locale: "zh-CN"}, + Config: pluginapi.Config{Instance: map[string]any{"credentials": map[string]any{"auth": "oauth"}}}, + }, pluginapi.OptionsInput{}, nil) + if pe, ok := pluginapi.AsError(err); !ok || pe.Message != msgConnectAccount.zh { + t.Fatalf("zh-CN hint = %v", err) + } + if err := r.call(pluginapi.ConnectorValidatePath("jira"), tokenInstance(f, nil), struct{}{}, nil); err != nil { + t.Fatalf("valid token: %v", err) + } + _, err = r.client.Stream( + context.Background(), + pluginapi.ConnectorFetchPath("jira"), + pluginapi.Envelope{Config: pluginapi.Config{Instance: tokenInstance(f, map[string]any{"jql": "BROKEN"})}}, + pluginapi.FetchInput{ + Mode: pluginapi.FetchFull, + ResourceIDs: []string{"ENG"}, + }, + func(pluginapi.Event) error { return nil }, + ) + if pe, ok := pluginapi.AsError(err); !ok || pe.Code != pluginapi.CodeInvalidConfig || + !strings.Contains(pe.Message, "JQL") { + t.Fatalf("bad jql = %v", err) + } +} + +func TestRateLimitsAreRetryable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Retry-After", "7") + w.WriteHeader(http.StatusTooManyRequests) + })) + defer srv.Close() + c := &client{api: srv.URL} + _, err := c.myself(context.Background()) + pe, ok := pluginapi.AsError(err) + if !ok || pe.Code != pluginapi.CodeRateLimited || !pe.Retryable || pe.Details.RetryAfter != 7 { + t.Fatalf("429 = %v", err) + } +} + +func TestADFToMarkdown(t *testing.T) { + doc, err := os.ReadFile("testdata/adf.json") + if err != nil { + t.Fatal(err) + } + want := "## Plan\n\n- one\n 1. nested\n- [docs](https://example.com) by @Ann\n\n```go\nx := 1\n```\n\n" + + "| a\\|b |\n| --- |\n| 1 |\n\n[attachment]\n\nkept" + if got := adfToMarkdown(doc); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } + if adfToMarkdown(json.RawMessage(`"plain"`)) != "plain" || adfToMarkdown(nil) != "" { + t.Fatal("plain strings pass through") + } +} + +func TestJQLDatesUseTheUsersZone(t *testing.T) { + since := time.Date(2026, 9, 22, 2, 0, 59, 0, time.UTC) + got := buildJQL("ENG", settings{}, since, shanghai) + if got != `project = "ENG" AND updated >= "2026/09/22 10:00" ORDER BY updated ASC` { + t.Fatal(got) + } +} + +// The example must stay an external plugin: the SDK and ordinary libraries, +// nothing from WeKnora's internals. +func TestPluginImportsNoInternals(t *testing.T) { + files, _ := filepath.Glob("*.go") + fset := token.NewFileSet() + for _, f := range files { + if strings.HasSuffix(f, "_test.go") { + continue + } + src, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + parsed, err := parser.ParseFile(fset, f, src, parser.ImportsOnly) + if err != nil { + t.Fatal(err) + } + for _, imp := range parsed.Imports { + if strings.Contains(imp.Path.Value, "github.com/Tencent/WeKnora/internal") { + t.Errorf("%s imports %s", f, imp.Path.Value) + } + } + } +} + +func TestPluginPassesConformance(t *testing.T) { + srv := httptest.NewServer(newPlugin().Handler()) + defer srv.Close() + rep := conformance.Run(context.Background(), conformance.Target{ + Client: pluginclient.New(srv.URL, nil, nil), + Raw: func(ctx context.Context, path string, body []byte) (*http.Response, error) { + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, srv.URL+path, bytes.NewReader(body)) + return http.DefaultClient.Do(req) + }, + }) + for _, r := range rep.Results { + if !r.Passed { + t.Errorf("%s: %s", r.Name, r.Detail) + } + } +} diff --git a/examples/plugins/jira/main.go b/examples/plugins/jira/main.go new file mode 100644 index 000000000..7ab396a67 --- /dev/null +++ b/examples/plugins/jira/main.go @@ -0,0 +1,451 @@ +// Command jira syncs Jira Cloud issues into WeKnora knowledge bases. It is the +// fullest example plugin: +// +// - a data source connector with incremental sync, checkpoints and +// deletions; +// - an OAuth field (x-oauth): WeKnora runs Atlassian's consent flow with +// the platform's OAuth app and hands the plugin a fresh access token; +// - dynamic choices (x-options): the sites an account can reach and the +// site's issue types; +// - a skill shipped in the package. +// +// Build a package with ./package.sh; install it under System administration +// → Plugin management. +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "slices" + "sort" + "strings" + "time" + "unicode/utf8" + + "github.com/Tencent/WeKnora/pluginsdk" + "github.com/Tencent/WeKnora/pluginsdk/pluginapi" +) + +// Version must match plugin.yaml. +const Version = "1.0.0" + +func main() { + if err := newPlugin().Serve(); err != nil { + log.Fatal(err) + } +} + +func newPlugin() *pluginsdk.Plugin { + p := pluginsdk.New(pluginsdk.Info{ID: "weknora-examples.jira", Version: Version}) + p.Connector("jira", connector{}) + p.Options("sites", siteOptions) + p.Options("issue_types", issueTypeOptions) + return p +} + +// settings are the connector's x-group: settings fields. +type settings struct { + IssueTypes []string `json:"issue_types"` + JQL string `json:"jql"` + IncludeComments *bool `json:"include_comments"` +} + +func (s settings) comments() bool { return s.IncludeComments == nil || *s.IncludeComments } + +func decode(cfg pluginsdk.ConnectorConfig) (credentials, settings, error) { + var cr credentials + var st settings + if err := remarshal(cfg.Credentials, &cr); err != nil { + return cr, st, pluginapi.InvalidConfig("credentials: "+err.Error(), nil) + } + if err := remarshal(cfg.Settings, &st); err != nil { + return cr, st, pluginapi.InvalidConfig("settings: "+err.Error(), nil) + } + return cr, st, nil +} + +func remarshal(in, out any) error { + b, err := json.Marshal(in) + if err != nil { + return err + } + return json.Unmarshal(b, out) +} + +type connector struct{} + +// Validate signs in to the site. +func (connector) Validate(ctx context.Context, call *pluginsdk.Call, cfg pluginsdk.ConnectorConfig) error { + cr, _, err := decode(cfg) + if err != nil { + return err + } + c, err := newClient(ctx, cr, call.Locale) + if err != nil { + return err + } + _, err = c.myself(ctx) + return err +} + +// ListResources offers the projects the user can browse. +func (connector) ListResources( + ctx context.Context, call *pluginsdk.Call, cfg pluginsdk.ConnectorConfig, parentID string, +) ([]pluginapi.Resource, error) { + if parentID != "" { + return nil, nil + } + cr, _, err := decode(cfg) + if err != nil { + return nil, err + } + c, err := newClient(ctx, cr, call.Locale) + if err != nil { + return nil, err + } + projects, err := c.projects(ctx) + if err != nil { + return nil, err + } + out := make([]pluginapi.Resource, 0, len(projects)) + for _, p := range projects { + out = append(out, pluginapi.Resource{ + ExternalID: p.Key, Name: p.Name, Type: "project", Description: p.Key, + URL: c.siteURL + "/browse/" + p.Key, + }) + } + return out, nil +} + +// syncState is the cursor: per project, the newest update seen (where the +// next incremental sync starts) and the issues it holds (so a full sync can +// tell which were deleted). +type syncState struct { + Projects map[string]*projectState `json:"projects"` +} + +type projectState struct { + Since time.Time `json:"since"` + IDs []string `json:"ids"` +} + +func stateFrom(c *pluginapi.Cursor) syncState { + st := syncState{Projects: map[string]*projectState{}} + if c != nil { + _ = remarshal(c.State, &st) + if st.Projects == nil { + st.Projects = map[string]*projectState{} + } + } + return st +} + +func (s syncState) cursor(now time.Time) pluginapi.Cursor { + var state map[string]any + _ = remarshal(s, &state) + return pluginapi.Cursor{LastSyncTime: &now, State: state} +} + +// Fetch syncs the selected projects. Issues come oldest update first, so the +// cursor can advance with every page; a full sync also reports the issues +// that disappeared (deleted, moved out, or no longer matching the filter). +func (connector) Fetch( + ctx context.Context, call *pluginsdk.Call, cfg pluginsdk.ConnectorConfig, in pluginapi.FetchInput, + s *pluginsdk.Stream, +) (*pluginapi.Cursor, error) { + cr, st, err := decode(cfg) + if err != nil { + return nil, err + } + keys := in.ResourceIDs + if len(keys) == 0 { + keys = cfg.ResourceIDs + } + if len(keys) == 0 { + return nil, pluginapi.InvalidConfig(say(call.Locale, msgNoProjects), nil) + } + c, err := newClient(ctx, cr, call.Locale) + if err != nil { + return nil, err + } + me, err := c.myself(ctx) + if err != nil { + return nil, err + } + // JQL reads dates in the user's time zone. + loc, err := time.LoadLocation(me.TimeZone) + if err != nil || me.TimeZone == "" { + loc = time.UTC + } + + prev := stateFrom(in.Cursor) + incremental := in.Mode == pluginapi.FetchIncremental && in.Cursor != nil + // Checkpoints are complete snapshots: projects not reached yet keep + // their previous state. + next := syncState{Projects: map[string]*projectState{}} + for _, key := range keys { + if p := prev.Projects[key]; p != nil { + cp := *p + next.Projects[key] = &cp + } else { + next.Projects[key] = &projectState{} + } + } + seen := map[string]bool{} + now := time.Now().UTC() + + for _, key := range keys { + ps := next.Projects[key] + var since time.Time + if incremental { + since = ps.Since + } + known := map[string]bool{} + for _, id := range ps.IDs { + known[id] = true + } + _ = s.Progress("syncing " + key) + err := c.search(ctx, buildJQL(key, st, since, loc), st.comments(), func(page []issue) error { + for i := range page { + is := &page[i] + if err := s.Item(c.item(is, key)); err != nil { + return err + } + seen[is.ID], known[is.ID] = true, true + if is.Fields.Updated.After(ps.Since) { + ps.Since = is.Fields.Updated.Time + } + } + ps.IDs = sortedKeys(known) + return s.Checkpoint(next.cursor(now)) + }) + if err != nil { + return nil, err + } + } + + if !incremental { + // Everything was listed: what the last sync had and this one did + // not see is gone. + for _, key := range keys { + var kept []string + if p := prev.Projects[key]; p != nil { + for _, id := range p.IDs { + if seen[id] { + continue + } + gone := pluginapi.FetchedItem{ExternalID: id, IsDeleted: true, SourceResourceID: key} + if err := s.Item(gone); err != nil { + return nil, err + } + } + } + for _, id := range next.Projects[key].IDs { + if seen[id] { + kept = append(kept, id) + } + } + next.Projects[key].IDs = kept + } + } + cur := next.cursor(now) + return &cur, nil +} + +func sortedKeys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// buildJQL is one project's query: the settings' filters, the issues updated +// since the last sync (to the minute, JQL's precision), oldest first. +func buildJQL(key string, st settings, since time.Time, loc *time.Location) string { + parts := []string{"project = " + quote(key)} + if len(st.IssueTypes) > 0 { + types := make([]string, len(st.IssueTypes)) + for i, t := range st.IssueTypes { + types[i] = quote(t) + } + parts = append(parts, "issuetype in ("+strings.Join(types, ", ")+")") + } + if extra := strings.TrimSpace(st.JQL); extra != "" { + parts = append(parts, "("+extra+")") + } + if !since.IsZero() { + parts = append(parts, "updated >= "+quote(since.In(loc).Format("2006/01/02 15:04"))) + } + return strings.Join(parts, " AND ") + " ORDER BY updated ASC" +} + +func quote(s string) string { + return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"` +} + +// item turns an issue into a Markdown document. +func (c *client) item(is *issue, projectKey string) pluginapi.FetchedItem { + f := &is.Fields + var b strings.Builder + fmt.Fprintf(&b, "# %s: %s\n\n", is.Key, f.Summary) + facts := [][2]string{ + {"Type", nameOf(f.IssueType)}, + {"Status", nameOf(f.Status)}, + {"Priority", nameOf(f.Priority)}, + {"Resolution", nameOf(f.Resolution)}, + {"Assignee", userOf(f.Assignee)}, + {"Reporter", userOf(f.Reporter)}, + {"Labels", strings.Join(f.Labels, ", ")}, + {"Created", stamp(f.Created)}, + {"Updated", stamp(f.Updated)}, + } + if f.Parent != nil { + facts = append(facts, [2]string{"Parent", f.Parent.Key}) + } + for _, kv := range facts { + if kv[1] != "" { + fmt.Fprintf(&b, "- **%s:** %s\n", kv[0], kv[1]) + } + } + url := c.siteURL + "/browse/" + is.Key + fmt.Fprintf(&b, "- **Link:** %s\n", url) + if desc := adfToMarkdown(f.Description); desc != "" { + b.WriteString("\n## Description\n\n" + desc + "\n") + } + if f.Comment != nil && len(f.Comment.Comments) > 0 { + b.WriteString("\n## Comments\n") + for _, cm := range f.Comment.Comments { + fmt.Fprintf(&b, "\n### %s · %s\n\n%s\n", userOf(cm.Author), stamp(cm.Created), adfToMarkdown(cm.Body)) + } + if more := f.Comment.Total - len(f.Comment.Comments); more > 0 { + fmt.Fprintf(&b, "\n_%d more comments are not included._\n", more) + } + } + created, updated := f.Created.Time, f.Updated.Time + return pluginapi.FetchedItem{ + ExternalID: is.ID, + Title: is.Key + " " + f.Summary, + Content: []byte(b.String()), + ContentType: "text/markdown", + FileName: fileName(is.Key + " " + f.Summary), + URL: url, + CreatedAt: nonZero(created), + UpdatedAt: nonZero(updated), + SourceResourceID: projectKey, + Metadata: map[string]string{ + "channel": "jira", "jira_key": is.Key, "project": projectKey, "status": nameOf(f.Status), + "issue_type": nameOf(f.IssueType), "assignee": userOf(f.Assignee), "priority": nameOf(f.Priority), + "labels": strings.Join(f.Labels, ","), + }, + } +} + +func nameOf(n *named) string { + if n == nil { + return "" + } + return n.Name +} + +func userOf(u *user) string { + if u == nil { + return "" + } + return u.DisplayName +} + +func stamp(t jiraTime) string { + if t.IsZero() { + return "" + } + return t.Format("2006-01-02 15:04 -0700") +} + +func nonZero(t time.Time) *time.Time { + if t.IsZero() { + return nil + } + return &t +} + +const maxFileNameBytes = 200 + +var fileNameReplacer = strings.NewReplacer( + "/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_", + "\n", " ", "\r", " ", "\t", " ", +) + +// fileName makes a title safe as a file name: hostile punctuation replaced, +// at most 200 bytes without splitting a rune. +func fileName(title string) string { + name := strings.TrimSpace(fileNameReplacer.Replace(title)) + if name == "" { + return "issue.md" + } + for len(name) > maxFileNameBytes { + _, size := utf8.DecodeLastRuneInString(name) + name = name[:len(name)-size] + } + return name + ".md" +} + +// siteOptions lists the Jira sites the connected account granted. +func siteOptions(ctx context.Context, call *pluginsdk.Call, _ pluginapi.OptionsInput) ([]pluginapi.Option, error) { + var cfg pluginsdk.ConnectorConfig + if err := call.DecodeInstance(&cfg); err != nil { + return nil, err + } + cr, _, err := decode(cfg) + if err != nil { + return nil, err + } + if cr.Account == "" { + return nil, pluginapi.InvalidConfig(say(call.Locale, msgConnectAccount), + map[string]string{"credentials.account": "required"}) + } + sites, err := accessibleSites(ctx, cr.Account) + if err != nil { + return nil, err + } + out := make([]pluginapi.Option, 0, len(sites)) + for _, s := range sites { + out = append(out, pluginapi.Option{Value: s.ID, Label: s.Name, Description: s.URL}) + } + return out, nil +} + +// issueTypeOptions lists the site's issue types by name (JQL matches names). +func issueTypeOptions(ctx context.Context, call *pluginsdk.Call, _ pluginapi.OptionsInput) ([]pluginapi.Option, error) { + var cfg pluginsdk.ConnectorConfig + if err := call.DecodeInstance(&cfg); err != nil { + return nil, err + } + cr, _, err := decode(cfg) + if err != nil { + return nil, err + } + c, err := newClient(ctx, cr, call.Locale) + if err != nil { + return nil, err + } + types, err := c.issueTypes(ctx) + if err != nil { + return nil, err + } + var names []string + for _, t := range types { + if !slices.Contains(names, t.Name) { + names = append(names, t.Name) + } + } + sort.Strings(names) + out := make([]pluginapi.Option, len(names)) + for i, n := range names { + out[i] = pluginapi.Option{Value: n} + } + return out, nil +} diff --git a/examples/plugins/jira/messages.go b/examples/plugins/jira/messages.go new file mode 100644 index 000000000..9bc631346 --- /dev/null +++ b/examples/plugins/jira/messages.go @@ -0,0 +1,23 @@ +package main + +import "strings" + +// Messages the form shows, in the user's language (call.Locale). Errors +// that quote Jira stay in Jira's words. +type message struct{ en, zh string } + +var ( + msgConnectAccount = message{"Connect an Atlassian account first.", "请先连接 Atlassian 账号。"} + msgChooseSite = message{"Choose a site.", "请选择站点。"} + msgSiteGone = message{"The account cannot reach this site any more.", "该账号已无法访问此站点。"} + msgTokenFields = message{"Enter the site URL, email and API token first.", "请先填写站点地址、邮箱和 API 令牌。"} + msgUnknownAuth = message{"Unknown sign-in method.", "未知的登录方式。"} + msgNoProjects = message{"Select at least one project to sync.", "请至少选择一个要同步的项目。"} +) + +func say(locale string, m message) string { + if strings.HasPrefix(strings.ToLower(locale), "zh") { + return m.zh + } + return m.en +} diff --git a/examples/plugins/jira/package.sh b/examples/plugins/jira/package.sh new file mode 100755 index 000000000..f9219fe29 --- /dev/null +++ b/examples/plugins/jira/package.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Builds the Jira example plugin for the platforms WeKnora runs on and zips it +# into a .wkp package: plugin.yaml, schemas, the skill, icon and bin/-/jira. +set -euo pipefail +cd "$(dirname "$0")" +version=$(sed -n 's/^version: //p' plugin.yaml) +out=${1:-"weknora-examples-jira-${version}.wkp"} +stage=$(mktemp -d) +trap 'rm -rf "$stage"' EXIT +cp plugin.yaml icon.svg "$stage"/ +cp -r schemas skills "$stage"/ +for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do + os=${target%/*} + arch=${target#*/} + bin="$stage/bin/${os}-${arch}/jira" + [ "$os" = windows ] && bin="$bin.exe" + CGO_ENABLED=0 GOOS=$os GOARCH=$arch go build -trimpath -ldflags="-s -w" -o "$bin" . +done +rm -f "$out" +(cd "$stage" && zip -qr - .) > "$out" +echo "wrote $out" diff --git a/examples/plugins/jira/plugin.yaml b/examples/plugins/jira/plugin.yaml new file mode 100644 index 000000000..8f08ce448 --- /dev/null +++ b/examples/plugins/jira/plugin.yaml @@ -0,0 +1,42 @@ +schemaVersion: 1 +id: weknora-examples.jira +version: 1.0.0 +apiVersion: weknora.plugin/v1 +name: { en-US: Jira, zh-CN: Jira } +description: + en-US: Sync Jira Cloud issues, with their comments, into a knowledge base, and a skill for triaging them. Connects with Atlassian OAuth or an API token; shows dynamic form options and OAuth fields. + zh-CN: 把 Jira Cloud 的问题(含评论)同步到知识库,并提供问题分诊技能。支持 Atlassian OAuth 授权或 API 令牌;演示表单动态选项与 OAuth 字段。 +publisher: { id: weknora-examples, name: WeKnora examples } +homepage: https://github.com/Tencent/WeKnora/tree/main/examples/plugins/jira +license: MIT +icon: icon.svg +runtime: + type: host + kind: binary + entry: "bin/{os}-{arch}/jira" + resources: { cpu: 500m, memory: 256Mi } +permissions: + # The OAuth API gateway and the sites themselves. WeKnora, not the plugin, + # talks to auth.atlassian.com. + egress: [api.atlassian.com, "*.atlassian.net"] +config: + # The platform's Atlassian OAuth 2.0 (3LO) app. Without it, workspaces can + # still connect with an API token. + system: schemas/system.yaml +contributes: + connectors: + - id: jira + name: { en-US: Jira, zh-CN: Jira } + description: + en-US: Issues of the selected Jira Cloud projects, with comments + zh-CN: 同步所选 Jira Cloud 项目的问题及评论 + icon: icon.svg + capabilities: [incremental, streaming] + instanceSchema: schemas/connector.yaml + skills: + - id: jira-triage + name: { en-US: Jira triage, zh-CN: Jira 问题分诊 } + description: + en-US: Find duplicates, related work and owners of a Jira issue in the synced issues + zh-CN: 在已同步的问题中查找重复、相关工作和负责人 + path: skills/jira-triage diff --git a/examples/plugins/jira/schemas/connector.yaml b/examples/plugins/jira/schemas/connector.yaml new file mode 100644 index 000000000..f45fec6d0 --- /dev/null +++ b/examples/plugins/jira/schemas/connector.yaml @@ -0,0 +1,99 @@ +type: object +# Fields hidden by x-visible-if are not required. +required: [auth, account, site, base_url, email, api_token] +properties: + auth: + type: string + title: Sign in with + default: oauth + x-order: 1 + oneOf: + - const: oauth + title: Atlassian account (OAuth) + x-i18n: { title: { zh-CN: Atlassian 账号(OAuth) } } + - const: token + title: API token + x-i18n: { title: { zh-CN: API 令牌 } } + x-i18n: + title: { zh-CN: 登录方式 } + account: + type: string + title: Atlassian account + description: Needs the platform's OAuth app (plugin management → Jira) + x-order: 2 + x-visible-if: { auth: oauth } + x-oauth: + authorizeUrl: https://auth.atlassian.com/authorize + tokenUrl: https://auth.atlassian.com/oauth/token + scopes: [read:jira-work, read:jira-user, offline_access] + clientId: ${system.client_id} + clientSecret: ${system.client_secret} + params: { audience: api.atlassian.com, prompt: consent } + x-i18n: + title: { zh-CN: Atlassian 账号 } + description: { zh-CN: 需要平台先配置 OAuth 应用(插件管理 → Jira) } + site: + type: string + title: Site + description: The Jira site the account can reach + x-order: 3 + x-visible-if: { auth: oauth } + x-options: { name: sites, dependsOn: [account] } + x-i18n: + title: { zh-CN: 站点 } + description: { zh-CN: 该账号可访问的 Jira 站点 } + base_url: + type: string + title: Site URL + x-order: 4 + x-visible-if: { auth: token } + x-placeholder: https://your-team.atlassian.net + pattern: "^https://[A-Za-z0-9-]+\\.atlassian\\.net/?$" + x-i18n: + title: { zh-CN: 站点地址 } + email: + type: string + title: Email + x-order: 5 + x-visible-if: { auth: token } + format: email + x-i18n: + title: { zh-CN: 邮箱 } + api_token: + type: string + title: API token + description: Create one at id.atlassian.com → Security → API tokens + x-order: 6 + x-secret: true + x-visible-if: { auth: token } + x-i18n: + title: { zh-CN: API 令牌 } + description: { zh-CN: 在 id.atlassian.com → 安全 → API 令牌 中创建 } + issue_types: + type: array + items: { type: string } + title: Issue types + description: Only these types; leave empty for all + x-order: 10 + x-group: settings + x-options: { name: issue_types, dependsOn: [auth, site, base_url, email] } + x-i18n: + title: { zh-CN: 问题类型 } + description: { zh-CN: 只同步这些类型,留空表示全部 } + jql: + type: string + title: Extra JQL filter + description: "Added to each project's query, e.g. statusCategory != Done" + x-order: 11 + x-group: settings + x-i18n: + title: { zh-CN: 附加 JQL 条件 } + description: { zh-CN: "追加到每个项目的查询,例如 statusCategory != Done" } + include_comments: + type: boolean + title: Include comments + default: true + x-order: 12 + x-group: settings + x-i18n: + title: { zh-CN: 包含评论 } diff --git a/examples/plugins/jira/schemas/system.yaml b/examples/plugins/jira/schemas/system.yaml new file mode 100644 index 000000000..eeef01988 --- /dev/null +++ b/examples/plugins/jira/schemas/system.yaml @@ -0,0 +1,17 @@ +type: object +properties: + client_id: + type: string + title: OAuth client ID + description: "Client ID of an OAuth 2.0 (3LO) app in the Atlassian developer console, with the Jira API scopes read:jira-work and read:jira-user and the callback URL /api/v1/plugin-oauth/callback" + x-order: 1 + x-i18n: + title: { zh-CN: OAuth 客户端 ID } + description: { zh-CN: "在 Atlassian 开发者控制台创建的 OAuth 2.0 (3LO) 应用的 Client ID。应用需开通 Jira API 的 read:jira-work 与 read:jira-user 权限,回调地址填 /api/v1/plugin-oauth/callback" } + client_secret: + type: string + title: OAuth client secret + x-secret: true + x-order: 2 + x-i18n: + title: { zh-CN: OAuth 客户端密钥 } diff --git a/examples/plugins/jira/skills/jira-triage/SKILL.md b/examples/plugins/jira/skills/jira-triage/SKILL.md new file mode 100644 index 000000000..27b5a927e --- /dev/null +++ b/examples/plugins/jira/skills/jira-triage/SKILL.md @@ -0,0 +1,40 @@ +--- +name: jira-triage +description: Triage a Jira issue against the issues synced into the knowledge base - find duplicates, related work, likely owners and a suggested priority. Use when the user pastes a Jira key or a bug report and asks whether it is known, who should take it, or how urgent it is. +--- +# Jira triage + +The Jira data source stores each issue as one Markdown document titled +` `. It starts with a fact list (Type, Status, Priority, +Resolution, Assignee, Reporter, Labels, Created, Updated, Parent, Link), then +`## Description` and `## Comments`. + +## Steps + +1. **Understand the report.** If the user gave a key (`ENG-123`), find that + issue first with `search_knowledge` on the key and read it with + `read_document`. Otherwise work from the text they pasted. Note the + component, the symptom, error messages and the version or environment. +2. **Look for duplicates.** Search with the symptom in a few phrasings and + with any exact error message. Read the closest matches. Treat as a + duplicate only when the symptom and the conditions both match, not only + the area. +3. **Find related work.** Look for open issues in the same area, recent + fixes (Resolution set, Updated recently) and parent epics. +4. **Suggest an owner.** Prefer the assignee of the duplicates or of recent + fixes in the same area. Say how many issues support the suggestion. +5. **Suggest a priority.** Compare with the priorities of similar issues and + weigh impact (data loss, security, many users) over annoyance. + +## Answer + +Reply in the user's language with: + +- **Verdict:** duplicate of ``, related to ``, or new. +- **Evidence:** one line per issue: key, summary, status, and why it + matches. Link each with the Link from its document. +- **Owner** and **priority** suggestions, with the reason. +- **Missing information** the reporter should add, if any. + +Only cite issues you read. The knowledge base may lag Jira by one sync; say so +when the newest issue you found is older than a day. diff --git a/examples/plugins/jira/testdata/adf.json b/examples/plugins/jira/testdata/adf.json new file mode 100644 index 000000000..0c0a7dc37 --- /dev/null +++ b/examples/plugins/jira/testdata/adf.json @@ -0,0 +1,160 @@ +{ + "type": "doc", + "version": 1, + "content": [ + { + "type": "heading", + "attrs": { + "level": 2 + }, + "content": [ + { + "type": "text", + "text": "Plan" + } + ] + }, + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "one" + } + ] + }, + { + "type": "orderedList", + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "nested" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "docs", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://example.com" + } + } + ] + }, + { + "type": "text", + "text": " by " + }, + { + "type": "mention", + "attrs": { + "text": "@Ann" + } + } + ] + } + ] + } + ] + }, + { + "type": "codeBlock", + "attrs": { + "language": "go" + }, + "content": [ + { + "type": "text", + "text": "x := 1" + } + ] + }, + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableHeader", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "a|b" + } + ] + } + ] + } + ] + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "1" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "mediaSingle", + "content": [ + { + "type": "media" + } + ] + }, + { + "type": "someFutureNode", + "content": [ + { + "type": "text", + "text": "kept" + } + ] + } + ] +} diff --git a/pluginsdk/README.md b/pluginsdk/README.md index d4a9878eb..8117131d6 100644 --- a/pluginsdk/README.md +++ b/pluginsdk/README.md @@ -279,6 +279,8 @@ Complete plugins with their `package.sh`: - `examples/plugins/notebooks`: a parser written in Python. - `examples/plugins/links`: pages (toolbox, settings, knowledge base tab), in Python. - `examples/plugins/activity`: events, a webhook and a page, in Python. +- `examples/plugins/jira`: a Jira Cloud connector with an OAuth field, dynamic + options, incremental sync with deletions, and a skill. ## Testing diff --git a/website-docs/03-features/25-plugins.md b/website-docs/03-features/25-plugins.md index c2b312eb3..6740aa96b 100644 --- a/website-docs/03-features/25-plugins.md +++ b/website-docs/03-features/25-plugins.md @@ -156,6 +156,7 @@ Helm 设置 `pluginHost.enabled=true` 即可。使用本地存储(`STORAGE_TYP - `subtitles`:文档解析器,Go,使用 Host API; - `notebooks`:Jupyter 笔记本解析器,Python; - `links`:带三种页面的团队链接插件,Python; - - `activity`:订阅事件、接收 Webhook 的空间动态插件,Python。 + - `activity`:订阅事件、接收 Webhook 的空间动态插件,Python; + - `jira`:Jira Cloud 问题同步,支持 OAuth 授权或 API 令牌,带动态选项、增量同步与问题分诊技能,Go。 同一个插件既可以打包成 `host` 插件由 WeKnora 运行,也可以作为 `remote` 服务独立部署,代码不用改。