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
This commit is contained in:
lyingbug
2026-09-26 14:48:00 +08:00
committed by GitHub
parent 01222aac8e
commit 4f5d475c3e
15 changed files with 1991 additions and 1 deletions
+59
View File
@@ -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
`<WeKnora address>/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:** `<KEY> <summary>`.
- **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.
+213
View File
@@ -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()
}
+357
View File
@@ -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
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="a" x1="15.5" y1="9.5" x2="10.4" y2="14.7" gradientUnits="userSpaceOnUse"><stop offset=".18" stop-color="#0052cc"/><stop offset="1" stop-color="#2684ff"/></linearGradient><linearGradient id="b" x1="16.6" y1="22.4" x2="21.7" y2="17.3" gradientUnits="userSpaceOnUse"><stop offset=".18" stop-color="#0052cc"/><stop offset="1" stop-color="#2684ff"/></linearGradient></defs><path fill="#2684ff" d="M28.2 15.1 17.3 4.2 16.2 3.2 8 11.4l-3.8 3.7a1 1 0 0 0 0 1.4l7.5 7.5 4.5 4.5 8.2-8.2.1-.1 3.7-3.7a1 1 0 0 0 0-1.4ZM16.2 19.5 12.4 15.8l3.8-3.8 3.7 3.8Z"/><path fill="url(#a)" d="M16.2 12a6.3 6.3 0 0 1 0-8.8L8 11.4l4.4 4.4Z"/><path fill="url(#b)" d="m19.9 15.8-3.7 3.7a6.3 6.3 0 0 1 0 8.9l8.2-8.2Z"/></svg>

After

Width:  |  Height:  |  Size: 784 B

+504
View File
@@ -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:<id>" 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)
}
}
}
+451
View File
@@ -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
}
+23
View File
@@ -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
}
+21
View File
@@ -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/<os>-<arch>/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"
+42
View File
@@ -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
@@ -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: 包含评论 }
+17
View File
@@ -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 <WeKnora address>/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 权限,回调地址填 <WeKnora 地址>/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 客户端密钥 }
@@ -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
`<KEY> <summary>`. 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 `<KEY>`, related to `<KEYS>`, 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.
+160
View File
@@ -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"
}
]
}
]
}
+2
View File
@@ -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
+2 -1
View File
@@ -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` 服务独立部署,代码不用改。