Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions cmd/gadak/link.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,16 @@ func cmdLink(args []string) error {
if err != nil {
return err
}
lt, reverse, err := origin.ResolveLinkType(token, catalog)
lt, inwardDescription, err := origin.ResolveLinkType(token, catalog)
if err != nil {
return err
}
outward, inward := a, b
if reverse {
outward, inward = b, a
// Jira displays type.outward when A is inwardIssue and type.inward
// when A is outwardIssue. Put A on the end that makes the token the
// phrase displayed on A.
outward, inward := b, a
if inwardDescription {
outward, inward = a, b
}
if err := linker.LinkIssues(ctx, lt.ID, outward, inward); err != nil {
return err
Expand Down
54 changes: 39 additions & 15 deletions cmd/gadak/link_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,17 @@ import (

// Contract ↔ assertion (GDK-19 link half):
//
// 1. --type blocks → POST type.id=10000, outward=A, inward=B
// TestLinkBlocksOutward
// 2. --type "is blocked by" → outward=B, inward=A
// TestLinkIsBlockedByReversesDirection
// 1. --type blocks → POST type.id=10000, outward=B, inward=A
// TestLinkOutwardDescriptionMakesFirstArgumentDisplayOutwardDescription
// 2. --type "is blocked by" → outward=A, inward=B
// TestLinkInwardDescriptionMakesFirstArgumentDisplayInwardDescription
// 3. unknown token lists the catalog; no POST
// TestLinkUnknownTokenListsCatalogAndDoesNotPOST
// 4. A==B refused locally; no catalog GET
// TestLinkSelfRefusedWithoutCatalogGET
// 5. success re-reads both keys (search/jql twice)
// TestLinkBlocksOutward (POST /search/jql count)
// TestLinkOutwardDescriptionMakesFirstArgumentDisplayOutwardDescription
// (POST /search/jql count)
// 6. Dispatch + help (blocks / is blocked by; distinct from issue --link)
// TestLinkIsRegisteredAndHelpShowsDirectionExamples

Expand Down Expand Up @@ -54,7 +55,10 @@ func countTagged(f *fakeJira, tag string) int {
return n
}

func TestLinkBlocksOutward(t *testing.T) {
// Jira renders type.outward on an issue when that issue appears as
// inwardIssue in its issueLinks response. Therefore the first CLI argument
// must be POSTed as inwardIssue when the caller uses an outward description.
func TestLinkOutwardDescriptionMakesFirstArgumentDisplayOutwardDescription(t *testing.T) {
f := newFakeJira(t)
mirror(t, f.URL)

Expand All @@ -68,8 +72,8 @@ func TestLinkBlocksOutward(t *testing.T) {
if id != "10000" {
t.Errorf("type.id = %q, want 10000; body %s", id, f.bodies["POST /issueLink"])
}
if outward != "NMB-1" || inward != "NMB-2" {
t.Errorf("outward=%q inward=%q, want NMB-1 / NMB-2", outward, inward)
if outward != "NMB-2" || inward != "NMB-1" {
t.Errorf("outward=%q inward=%q, want NMB-2 / NMB-1", outward, inward)
}
if strings.Contains(out, "NMB-1\t완료\t") == false {
t.Fatalf("stale line %q", out)
Expand All @@ -82,7 +86,10 @@ func TestLinkBlocksOutward(t *testing.T) {
}
}

func TestLinkIsBlockedByReversesDirection(t *testing.T) {
// Jira renders type.inward on an issue when that issue appears as
// outwardIssue in its issueLinks response. The first CLI argument therefore
// remains outwardIssue for an inward description.
func TestLinkInwardDescriptionMakesFirstArgumentDisplayInwardDescription(t *testing.T) {
f := newFakeJira(t)
mirror(t, f.URL)

Expand All @@ -96,8 +103,25 @@ func TestLinkIsBlockedByReversesDirection(t *testing.T) {
if id != "10000" {
t.Errorf("type.id = %q, want 10000", id)
}
if outward != "NMB-2" || inward != "NMB-1" {
t.Errorf("outward=%q inward=%q, want NMB-2 / NMB-1 (reversed)", outward, inward)
if outward != "NMB-1" || inward != "NMB-2" {
t.Errorf("outward=%q inward=%q, want NMB-1 / NMB-2", outward, inward)
}
}

func TestLinkSplitFromMakesFirstArgumentDisplayInwardDescription(t *testing.T) {
f := newFakeJira(t)
f.linkTypesJSON = `{"issueLinkTypes":[{"id":"10001","name":"Issue split","outward":"split to","inward":"split from"}]}`
mirror(t, f.URL)

_, err := capture(t, func() error {
return cmdLink([]string{"NMB-1", "NMB-2", "--type", "split from"})
})
if err != nil {
t.Fatalf("link: %v", err)
}
id, outward, inward := postedIssueLink(t, f)
if id != "10001" || outward != "NMB-1" || inward != "NMB-2" {
t.Errorf("id=%q outward=%q inward=%q, want 10001 / NMB-1 / NMB-2", id, outward, inward)
}
}

Expand Down Expand Up @@ -154,8 +178,8 @@ func TestLinkTypeIDUsesOutwardConvention(t *testing.T) {
t.Fatalf("link --type 10000: %v", err)
}
id, outward, inward := postedIssueLink(t, f)
if id != "10000" || outward != "NMB-1" || inward != "NMB-2" {
t.Errorf("id=%q outward=%q inward=%q", id, outward, inward)
if id != "10000" || outward != "NMB-2" || inward != "NMB-1" {
t.Errorf("id=%q outward=%q inward=%q, want 10000 / NMB-2 / NMB-1", id, outward, inward)
}
}

Expand Down Expand Up @@ -218,8 +242,8 @@ func TestLinkSymmetricTypeIsNotAmbiguous(t *testing.T) {
if id != "10003" {
t.Errorf("type.id = %q, want 10003", id)
}
if outward != "NMB-1" || inward != "NMB-2" {
t.Errorf("outward=%q inward=%q, want NMB-1 / NMB-2", outward, inward)
if outward != "NMB-2" || inward != "NMB-1" {
t.Errorf("outward=%q inward=%q, want NMB-2 / NMB-1", outward, inward)
}
}

Expand Down
2 changes: 1 addition & 1 deletion docs/MIRROR.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ gadak transition NMB-140 done --resolution "Won't Do" -m "fixed in 1.2"
gadak assign NMB-140 dana@example.com # email, display name, or accountId
gadak assign NMB-140 - # unassign
gadak claim NMB-140 # take it as yours: assignee + in-progress transition; held issues refuse (exit 75)
gadak link NMB-140 NMB-141 --type blocks # A blocks B; --type "is blocked by" reverses
gadak link NMB-140 NMB-141 --type blocks # A blocks B; "is blocked by" means A is blocked by B

gadak fields # custom-field usage on a sample (needs credential)
gadak fields --sample 100 --project NMB --json
Expand Down
6 changes: 3 additions & 3 deletions internal/jira/write.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,9 @@ func (c *Client) IssueLinkTypes(ctx context.Context) ([]IssueLinkType, error) {
return out.IssueLinkTypes, c.do(ctx, http.MethodGet, apiPath+"/issueLinkType", nil, &out)
}

// LinkIssues is POST /rest/api/3/issueLink. outwardIssue is the subject of
// the type's outward description (MKY-1 duplicates HSP-1 when outward is
// MKY-1). 201/200 with an empty body is success.
// LinkIssues is POST /rest/api/3/issueLink. On an issue response, the issue
// at outwardIssue displays the type's inward description; inwardIssue displays
// the outward description. 201/200 with an empty body is success.
func (c *Client) LinkIssues(ctx context.Context, typeID, outwardKey, inwardKey string) error {
body := map[string]any{
"type": map[string]string{"id": typeID},
Expand Down
24 changes: 13 additions & 11 deletions internal/origin/linkresolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,20 @@ import (
// each other. A parity test is a way to notice a drift, not a way to prevent
// one: there is one owner now and nothing to keep in step.

// LinkTypeHit is one catalog entry that matched a token, with the direction
// the match implies.
// LinkTypeHit is one catalog entry that matched a token and whether it is the
// type's inward description.
type LinkTypeHit struct {
Type jira.IssueLinkType
Reverse bool
Type jira.IssueLinkType
InwardDescription bool
}

// ResolveLinkType matches token against the catalog. An all-digit token is a
// type id and keeps the outward convention. Otherwise the type name and its
// outward description keep A as the outward side; a match on the inward
// description swaps A and B. reverse reports that swap.
func ResolveLinkType(token string, catalog []jira.IssueLinkType) (lt jira.IssueLinkType, reverse bool, err error) {
// type id and uses the outward description convention. Otherwise the type name
// and its outward description are outward; a match on the inward description
// reports inwardDescription. The caller assigns POST issue ends because Jira's
// issue response displays type.inward for outwardIssue and type.outward for
// inwardIssue.
func ResolveLinkType(token string, catalog []jira.IssueLinkType) (lt jira.IssueLinkType, inwardDescription bool, err error) {
token = strings.TrimSpace(token)
if token == "" {
return jira.IssueLinkType{}, false, fmt.Errorf("empty link type")
Expand All @@ -53,14 +55,14 @@ func ResolveLinkType(token string, catalog []jira.IssueLinkType) (lt jira.IssueL
// Both descriptions of one type match only when they are equal
// (a symmetric type like Relates) — direction is meaningless
// there, so this is one hit, not an ambiguity.
hits = append(hits, LinkTypeHit{Type: t, Reverse: false})
hits = append(hits, LinkTypeHit{Type: t, InwardDescription: false})
continue
}
hits = append(hits, LinkTypeHit{Type: t, Reverse: inwardDir})
hits = append(hits, LinkTypeHit{Type: t, InwardDescription: inwardDir})
}
switch len(hits) {
case 1:
return hits[0].Type, hits[0].Reverse, nil
return hits[0].Type, hits[0].InwardDescription, nil
case 0:
return jira.IssueLinkType{}, false, fmt.Errorf("no link type matching %q — available: %s", token, FormatLinkTypes(catalog))
default:
Expand Down
11 changes: 7 additions & 4 deletions internal/server/link.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,17 @@ func (s *server) handleLink(w http.ResponseWriter, r *http.Request) {
failJira(w, r, s.config(), err)
return
}
lt, reverse, err := origin.ResolveLinkType(token, catalog)
lt, inwardDescription, err := origin.ResolveLinkType(token, catalog)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
outward, inward := a, b
if reverse {
outward, inward = b, a
// Jira displays type.outward when A is inwardIssue and type.inward when A
// is outwardIssue. Put A on the end that makes the requested token the
// phrase displayed on A.
outward, inward := b, a
if inwardDescription {
outward, inward = a, b
}
if err := linker.LinkIssues(r.Context(), lt.ID, outward, inward); err != nil {
failJira(w, r, s.config(), err)
Expand Down
36 changes: 18 additions & 18 deletions internal/server/link_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,16 @@ func TestLinkTypesREST(t *testing.T) {
}
}

func TestLinkRESTBlocksOutward(t *testing.T) {
func TestLinkRESTOutwardDescriptionMakesPathIssueDisplayOutwardDescription(t *testing.T) {
f, h, _ := writable(t)

rec := send(t, h, http.MethodPost, apiBase+"NMB-1/link/", `{"type":"blocks","key":"NMB-2"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
id, outward, inward := postedIssueLink(t, f)
if id != "10000" || outward != "NMB-1" || inward != "NMB-2" {
t.Errorf("id=%q outward=%q inward=%q, want 10000 / NMB-1 / NMB-2", id, outward, inward)
if id != "10000" || outward != "NMB-2" || inward != "NMB-1" {
t.Errorf("id=%q outward=%q inward=%q, want 10000 / NMB-2 / NMB-1", id, outward, inward)
}
if n := countTagged(f, "GET /issueLinkType"); n != 1 {
t.Errorf("catalog GET count %d, want 1; calls %v", n, f.calls)
Expand Down Expand Up @@ -130,7 +130,7 @@ func TestLinkRESTBlocksOutward(t *testing.T) {
}
}

func TestLinkRESTInwardReversesDirection(t *testing.T) {
func TestLinkRESTInwardDescriptionMakesPathIssueDisplayInwardDescription(t *testing.T) {
f, h, _ := writable(t)

rec := send(t, h, http.MethodPost, apiBase+"NMB-1/link/",
Expand All @@ -139,8 +139,8 @@ func TestLinkRESTInwardReversesDirection(t *testing.T) {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
id, outward, inward := postedIssueLink(t, f)
if id != "10000" || outward != "NMB-2" || inward != "NMB-1" {
t.Errorf("id=%q outward=%q inward=%q, want 10000 / NMB-2 / NMB-1 (reversed)", id, outward, inward)
if id != "10000" || outward != "NMB-1" || inward != "NMB-2" {
t.Errorf("id=%q outward=%q inward=%q, want 10000 / NMB-1 / NMB-2", id, outward, inward)
}
}

Expand Down Expand Up @@ -234,17 +234,17 @@ func TestResolveLinkTypeMatchesCLI(t *testing.T) {
cat := []jira.IssueLinkType{
{ID: "10000", Name: "Blocks", Outward: "blocks", Inward: "is blocked by"},
}
lt, reverse, err := origin.ResolveLinkType("blocks", cat)
if err != nil || lt.ID != "10000" || reverse {
t.Fatalf("blocks: id=%q reverse=%v err=%v", lt.ID, reverse, err)
lt, inwardDescription, err := origin.ResolveLinkType("blocks", cat)
if err != nil || lt.ID != "10000" || inwardDescription {
t.Fatalf("blocks: id=%q inwardDescription=%v err=%v", lt.ID, inwardDescription, err)
}
lt, reverse, err = origin.ResolveLinkType("is blocked by", cat)
if err != nil || lt.ID != "10000" || !reverse {
t.Fatalf("inward: id=%q reverse=%v err=%v", lt.ID, reverse, err)
lt, inwardDescription, err = origin.ResolveLinkType("is blocked by", cat)
if err != nil || lt.ID != "10000" || !inwardDescription {
t.Fatalf("inward: id=%q inwardDescription=%v err=%v", lt.ID, inwardDescription, err)
}
lt, reverse, err = origin.ResolveLinkType("10000", cat)
if err != nil || lt.ID != "10000" || reverse {
t.Fatalf("id: id=%q reverse=%v err=%v", lt.ID, reverse, err)
lt, inwardDescription, err = origin.ResolveLinkType("10000", cat)
if err != nil || lt.ID != "10000" || inwardDescription {
t.Fatalf("id: id=%q inwardDescription=%v err=%v", lt.ID, inwardDescription, err)
}
_, _, err = origin.ResolveLinkType("clones", cat)
if err == nil || !strings.Contains(err.Error(), `no link type matching "clones"`) {
Expand All @@ -254,9 +254,9 @@ func TestResolveLinkTypeMatchesCLI(t *testing.T) {
sym := []jira.IssueLinkType{
{ID: "10003", Name: "Relates", Outward: "relates to", Inward: "relates to"},
}
lt, reverse, err = origin.ResolveLinkType("relates to", sym)
if err != nil || lt.ID != "10003" || reverse {
t.Fatalf("symmetric: id=%q reverse=%v err=%v", lt.ID, reverse, err)
lt, inwardDescription, err = origin.ResolveLinkType("relates to", sym)
if err != nil || lt.ID != "10003" || inwardDescription {
t.Fatalf("symmetric: id=%q inwardDescription=%v err=%v", lt.ID, inwardDescription, err)
}

amb := []jira.IssueLinkType{
Expand Down
2 changes: 1 addition & 1 deletion skills/gadak/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,7 @@ gadak edit NMB-140 -m "plain-text body" # plain replace; a formatted descr
gadak edit NMB-140 --component +SDK --component -Docs
gadak edit NMB-140 --fix-version +v2.5 --fix-version -10012
gadak edit NMB-140 --field severity=High
gadak link NMB-140 NMB-141 --type blocks # A blocks B; --type "is blocked by" reverses
gadak link NMB-140 NMB-141 --type blocks # A blocks B; "is blocked by" means A is blocked by B

gadak create --batch - # one JSON object per line on stdin (stops at the first failure)
gadak comment --batch - # JSON lines {"key","body"}; tries every line; one envelope row per key
Expand Down