Skip to content

feat: GitLab sync — dedup fixes, type filtering, epic→milestone mapping, work item hierarchy#2889

Closed
ktoulgaridis wants to merge 10 commits into
gastownhall:mainfrom
ktoulgaridis:feat/gitlab-sync-redesign
Closed

feat: GitLab sync — dedup fixes, type filtering, epic→milestone mapping, work item hierarchy#2889
ktoulgaridis wants to merge 10 commits into
gastownhall:mainfrom
ktoulgaridis:feat/gitlab-sync-redesign

Conversation

@ktoulgaridis

@ktoulgaridis ktoulgaridis commented Mar 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Pull-side fixes:

  • Fix dedup: GetIssueByExternalRefInTx checks both issues and wisps tables
  • URL pattern matches both /issues/ and /work_items/ paths (GitLab CE returns work_items URLs)

Push-side fixes:

  • Default-exclude molecule, message, and event types from GitLab push (internal coordination beads were leaking as GitLab issues)
  • --type and --exclude-type CLI flags for explicit control

Epic → Milestone mapping (new):

  • type=epic beads create/update GitLab milestones instead of issues (GitLab CE has no Epic work item type)
  • Non-epic issues with a parent-child dep to an epic automatically get milestone_id set
  • Closing an epic bead closes the milestone

Story → Task hierarchy (new):

  • type=task beads with a parent-child dep to a story/feature create GitLab Task work items (not regular Issues) via GraphQL
  • Parent-child hierarchy widget links the Task to its parent Issue
  • Enables the full 3-level hierarchy in GitLab CE: Epic (milestone) → Story/Feature (issue) → Task (child work item)
  • Requires gitlab.project_path config for GraphQL API calls

Status label mapping:

  • All beads statuses (in_progress, blocked, deferred, review, done) mapped to GitLab scoped labels for board columns

Config

New optional config key:

  • gitlab.project_path — GitLab project path (e.g., socwave/socwave). Required for Task work item creation via GraphQL.

Test plan

  • Epics sync as milestones, not issues
  • Stories/features assigned to parent epic's milestone
  • Tasks created as GitLab Task work items with parent hierarchy
  • mol-refinery-patrol and other internal types excluded from push
  • Pull dedup works with work_items URLs
  • go test ./internal/gitlab/... passes
  • Manual verification: GitLab board shows milestones, child items section shows tasks

…URLs

GetIssueByExternalRefInTx only queried the issues table, missing pushed
wisps that have external_ref set in the wisps table. Also, GitLab CE can
return work_items URLs instead of issues URLs, causing IsExternalRef and
ExtractIdentifier to fail on valid refs.

- Check wisps table as fallback in GetIssueByExternalRefInTx
- Expand issueIIDPattern to match both /issues/ and /work_items/ paths
- Set source_system on push-create for additional dedup resilience
- Add test cases for work_items URL matching

Executed-By: mayor
Wire up --type, --exclude-type, and --no-ephemeral flags for bd gitlab
sync. ExcludeEphemeral defaults to true so wisps/agent beads are never
pushed to GitLab unless explicitly opted in with --no-ephemeral=false.

This enables the sync hierarchy design: epics/stories/tasks sync to
GitLab while wisps and internal coordination beads stay local.

Executed-By: mayor
Internal coordination types (molecule, message, event) should never be
pushed to GitLab. Add them as default ExcludeTypes when the user hasn't
provided an explicit --type whitelist or --exclude-type override.

Executed-By: mayor
The source_system field doesn't exist in the storage schema, causing
UpdateIssue to reject the entire update map — including the critical
external_ref field. This left pushed issues without local external_refs,
causing duplicate creation on subsequent syncs.

Verified fix: push creates GitLab issue, saves external_ref locally,
subsequent pull recognizes the issue via external_ref (including
work_items URL format) and skips it.

Executed-By: mayor
Push status::open for open issues and status::done for closed issues
(previously only in_progress/blocked/deferred were mapped). Pull now
handles status::review (→ in_progress) and status::done (→ closed).

This enables GitLab boards with columns: Open, In Progress, Review,
Deferred, Done — all driven by scoped status:: labels.

Executed-By: mayor
Pull-side mapping now recognizes status::review (maps to in_progress)
and status::done (maps to closed) labels from GitLab. Push-side remains
non-opinionated: only sets status labels for in_progress, blocked, and
deferred — open and closed are handled by GitLab's native issue state.

This lets users configure custom board columns without beads imposing
a specific workflow. Tests added for new pull-side mappings.

Executed-By: mayor
@ktoulgaridis

ktoulgaridis commented Mar 29, 2026

Copy link
Copy Markdown
Contributor Author

@steveyegge a note on the design choices here.

We initially went down a path of trying to support arbitrary GitLab board workflows (custom status labels like status::review, status::done, etc.) but pulled back from that. It was getting opinionated in ways that don't belong in a library.

Instead, we opted for a 1-to-1 mapping of beads statuses to GitLab:

  • open / closed → GitLab's native issue state (no extra labels needed)
  • in_progress, blocked, deferred → scoped status:: labels

The push side stays minimal — it only sets labels for the states that GitLab doesn't represent natively. The pull side recognizes status::doneclosed for users who set that up, but doesn't impose it.

This means beads drives the workflow, and GitLab is just the viewport. Users who want custom board columns (like "Review" or "QA") can add them at the project level without beads needing to know about them.

The formatting commit at the end is a pre-existing gofmt issue on main — not from our changes, but CI catches it on the PR diff.

Let me know if you envision this working differently in any way, but I have been toying with this idea of exposing the highest levels of beads to human eyes, for the illusion of control.

@codecov-commenter

codecov-commenter commented Mar 29, 2026

Copy link
Copy Markdown

❌ 6 Tests Failed:

Tests completed Failed Passed Skipped
5696 6 5690 575
View the top 3 failed test(s) by shortest run time
github.com/steveyegge/beads/cmd/bd::TestCheckBeadGate_InvalidFormat
Stack Traces | 0s run time
=== RUN   TestCheckBeadGate_InvalidFormat
--- FAIL: TestCheckBeadGate_InvalidFormat (0.00s)
github.com/steveyegge/beads/cmd/bd::TestCheckBeadGate_InvalidFormat/empty
Stack Traces | 0s run time
=== RUN   TestCheckBeadGate_InvalidFormat/empty
    gate_test.go:104: reason "cross-rig bead gate \"\" cannot be checked (multi-rig routing removed)" does not contain "invalid await_id format"
--- FAIL: TestCheckBeadGate_InvalidFormat/empty (0.00s)
github.com/steveyegge/beads/cmd/bd::TestCheckBeadGate_InvalidFormat/missing_bead
Stack Traces | 0s run time
=== RUN   TestCheckBeadGate_InvalidFormat/missing_bead
    gate_test.go:104: reason "cross-rig bead gate \"my-project:\" cannot be checked (multi-rig routing removed)" does not contain "await_id missing rig name or bead ID"
--- FAIL: TestCheckBeadGate_InvalidFormat/missing_bead (0.00s)
github.com/steveyegge/beads/cmd/bd::TestCheckBeadGate_InvalidFormat/missing_rig
Stack Traces | 0s run time
=== RUN   TestCheckBeadGate_InvalidFormat/missing_rig
    gate_test.go:104: reason "cross-rig bead gate \":gt-abc\" cannot be checked (multi-rig routing removed)" does not contain "await_id missing rig name"
--- FAIL: TestCheckBeadGate_InvalidFormat/missing_rig (0.00s)
github.com/steveyegge/beads/cmd/bd::TestCheckBeadGate_InvalidFormat/no_colon
Stack Traces | 0s run time
=== RUN   TestCheckBeadGate_InvalidFormat/no_colon
    gate_test.go:104: reason "cross-rig bead gate \"my-project-mp-abc\" cannot be checked (multi-rig routing removed)" does not contain "invalid await_id format"
--- FAIL: TestCheckBeadGate_InvalidFormat/no_colon (0.00s)
github.com/steveyegge/beads/cmd/bd::TestCheckBeadGate_RigNotFound
Stack Traces | 0s run time
=== RUN   TestCheckBeadGate_RigNotFound
    gate_test.go:135: reason should mention not found: "cross-rig bead gate \"nonexistent:some-id\" cannot be checked (multi-rig routing removed)"
--- FAIL: TestCheckBeadGate_RigNotFound (0.00s)

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

GitLab CE has no Epic work item type, so epics were being pushed as
regular issues — polluting the board. Now:
- type=epic beads create/update GitLab milestones
- Non-epic issues with a parent-child dep to an epic get the milestone
  assigned automatically via milestone_id
- Milestone external refs use /-/milestones/<id> URL pattern
- IsExternalRef/ExtractIdentifier handle both issue and milestone URLs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Executed-By: mayor
When a bead of type=task has a parent-child dependency to a story or
feature, it is now created as a GitLab Task work item (not a regular
Issue) with the parent set via the hierarchy widget. This enables the
full 3-level hierarchy in GitLab CE:

  Epic (milestone) → Story/Feature (issue) → Task (child work item)

Uses GraphQL API for work item creation since the REST API only creates
Issue-type work items. Requires gitlab.project_path config to be set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Executed-By: mayor
@ktoulgaridis ktoulgaridis changed the title fix: GitLab sync dedup + type-based filtering feat: GitLab sync — dedup fixes, type filtering, epic→milestone mapping, work item hierarchy Mar 29, 2026
@ktoulgaridis

Copy link
Copy Markdown
Contributor Author

New commits: GitLab work item hierarchy support

Two new commits on this branch:

1. feat: map epic beads to GitLab milestones instead of issues
GitLab CE has no Epic work item type, so we map type=epic beads to milestones. Child issues automatically get milestone_id set from their parent epic's milestone. This gives us the grouping and progress tracking that epics provide in GitLab EE.

2. feat: create task beads as GitLab Task work items with parent hierarchy
When a type=task bead has a parent-child dep to a story/feature, it's created as a GitLab Task (not Issue) via GraphQL, with the parent hierarchy widget linking it to the story. This enables the full 3-level hierarchy in GitLab CE:

Epic (milestone) → Story/Feature (issue) → Task (child work item)

The GraphQL path requires a new config key gitlab.project_path (e.g., socwave/socwave). Without it, tasks fall back to regular issue creation.

Tested against GitLab CE 18.10 — milestones, issues, and task work items all created correctly with proper hierarchy links.

findParentEpicMilestone now walks up the parent-child chain (up to 5
levels) to find an ancestor epic, not just the direct parent. This
fixes milestone assignment for tasks whose parent is a story (task →
story → epic).

Also fixes milestone ID lookup: the URL contains the IID (project-
scoped) but milestone_id needs the API ID (global). Now fetches
milestones from the API and matches by IID.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Executed-By: mayor
steveyegge added a commit that referenced this pull request Apr 3, 2026
…ng, work item hierarchy (#2889)

- Fix pull dedup: check wisps table and match work_items URLs
- Add type-based filtering to GitLab sync CLI
- Default-exclude molecule/message/event types from push
- Fix source_system update that breaks external_ref persistence
- Map all beads statuses to GitLab scoped labels for board columns
- Handle status::review and status::done labels on pull
- Map epic beads to GitLab milestones instead of issues
- Create task beads as GitLab Task work items with parent hierarchy
- Walk parent chain to find epic milestone, use API ID not IID

Co-authored-by: ktoulgaridis <ktoulg@gmail.com>

Executed-By: beads/crew/emma
@steveyegge

Copy link
Copy Markdown
Contributor

Merged via squash-merge after rebase conflict resolution. Thank you!

@steveyegge steveyegge closed this Apr 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants