mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-10-13 00:44:45 +08:00
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:
@@ -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.
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Executable
+21
@@ -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"
|
||||
@@ -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: 包含评论 }
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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` 服务独立部署,代码不用改。
|
||||
|
||||
Reference in New Issue
Block a user