From 57fe824fec23942202393042ba19583063484ec2 Mon Sep 17 00:00:00 2001 From: Heejoon Lee Date: Mon, 31 Aug 2026 15:17:14 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9D=B4=EC=8A=88=20=EB=A7=81=ED=81=AC?= =?UTF-8?q?=20=EB=B0=A9=ED=96=A5=EC=9D=84=20Jira=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=EC=B6=95=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gadak link와 REST 링크 생성이 outwardIssue와 inwardIssue를 반대로 배치하던 문제를 바로잡는다. 링크 타입의 outward·inward 설명에 따라 첫 번째 이슈에 요청한 문구가 표시되도록 요청 끝점을 배치한다. CLI와 REST 회귀 테스트에 blocks, is blocked by, split from, type-id, 대칭 링크 사례를 반영하고 사용자 문서와 에이전트 스킬의 안내를 같은 계약으로 갱신한다. --- cmd/gadak/link.go | 11 ++++--- cmd/gadak/link_test.go | 54 ++++++++++++++++++++++++---------- docs/MIRROR.md | 2 +- internal/jira/write.go | 6 ++-- internal/origin/linkresolve.go | 24 ++++++++------- internal/server/link.go | 11 ++++--- internal/server/link_test.go | 36 +++++++++++------------ skills/gadak/SKILL.md | 2 +- 8 files changed, 89 insertions(+), 57 deletions(-) diff --git a/cmd/gadak/link.go b/cmd/gadak/link.go index 13b7f6a6..95684849 100644 --- a/cmd/gadak/link.go +++ b/cmd/gadak/link.go @@ -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 diff --git a/cmd/gadak/link_test.go b/cmd/gadak/link_test.go index f09c8d3f..ae33761b 100644 --- a/cmd/gadak/link_test.go +++ b/cmd/gadak/link_test.go @@ -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 @@ -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) @@ -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) @@ -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) @@ -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) } } @@ -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) } } @@ -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) } } diff --git a/docs/MIRROR.md b/docs/MIRROR.md index 4aa814ff..0d5a213d 100644 --- a/docs/MIRROR.md +++ b/docs/MIRROR.md @@ -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 diff --git a/internal/jira/write.go b/internal/jira/write.go index 739a0a77..953c856d 100644 --- a/internal/jira/write.go +++ b/internal/jira/write.go @@ -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}, diff --git a/internal/origin/linkresolve.go b/internal/origin/linkresolve.go index a51311a5..0f944404 100644 --- a/internal/origin/linkresolve.go +++ b/internal/origin/linkresolve.go @@ -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") @@ -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: diff --git a/internal/server/link.go b/internal/server/link.go index de5f2785..de8ae572 100644 --- a/internal/server/link.go +++ b/internal/server/link.go @@ -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) diff --git a/internal/server/link_test.go b/internal/server/link_test.go index 0f2a226e..dac529a7 100644 --- a/internal/server/link_test.go +++ b/internal/server/link_test.go @@ -87,7 +87,7 @@ 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"}`) @@ -95,8 +95,8 @@ func TestLinkRESTBlocksOutward(t *testing.T) { 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) @@ -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/", @@ -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) } } @@ -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"`) { @@ -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{ diff --git a/skills/gadak/SKILL.md b/skills/gadak/SKILL.md index e00e47d9..aa4aec98 100644 --- a/skills/gadak/SKILL.md +++ b/skills/gadak/SKILL.md @@ -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