Skip to content

Commit b6b2e30

Browse files
authored
Merge pull request #175 from linuxfoundation/feat/LFXV2-2013-ocg-external-sources
feat(committee): add external_sources field to committee base (LFXV2-2013)
2 parents 988d9ca + 713013b commit b6b2e30

17 files changed

Lines changed: 1197 additions & 35 deletions

File tree

cmd/committee-api/design/type.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ func CommitteeBaseAttributes() {
3636
ScopeAttribute()
3737
DeliverablesAttribute()
3838
KeyDatesAttribute()
39+
ExternalSourcesAttribute()
3940
}
4041

4142
// CommitteeSettings is the DSL type for a committee settings.
@@ -274,6 +275,61 @@ func KeyDatesAttribute() {
274275
})
275276
}
276277

278+
// ExternalSourceType is the DSL type for a single external source linked to a committee.
279+
var ExternalSourceType = dsl.Type("external-source", func() {
280+
dsl.Description("A single source-labeled external entity linked to a committee (e.g. an OCG group or event).")
281+
dsl.Attribute("provider", dsl.String, "The external platform that owns this linked entity", func() {
282+
dsl.Enum("ocg")
283+
dsl.Example("ocg")
284+
})
285+
dsl.Attribute("entity_type", dsl.String, "The type of entity in the external platform", func() {
286+
dsl.Enum("community", "group", "event")
287+
dsl.Example("group")
288+
})
289+
dsl.Attribute("label", dsl.String, "Human-readable label for the linked external entity", func() {
290+
dsl.MaxLength(200)
291+
dsl.Example("CNCF Meetup - San Francisco")
292+
})
293+
dsl.Attribute("url", dsl.String, "The URL of the linked external entity", func() {
294+
dsl.Format(dsl.FormatURI)
295+
dsl.Pattern(urlPattern)
296+
dsl.MaxLength(2048)
297+
dsl.Example("https://community.cncf.io/cncf-meetup-san-francisco/")
298+
})
299+
dsl.Attribute("external_id", dsl.String, "The identifier of the entity within the external platform", func() {
300+
dsl.MaxLength(200)
301+
dsl.Example("cncf-meetup-san-francisco")
302+
})
303+
dsl.Attribute("external_category", dsl.String, "The community-managed category of the entity in the external platform", func() {
304+
dsl.MaxLength(200)
305+
dsl.Example("Meetup")
306+
})
307+
dsl.Attribute("external_region", dsl.String, "The community-managed region of the entity in the external platform", func() {
308+
dsl.MaxLength(200)
309+
dsl.Example("North America")
310+
})
311+
dsl.Attribute("external_event_category", dsl.String, "The community-managed event category of the entity in the external platform", func() {
312+
dsl.MaxLength(200)
313+
dsl.Example("Virtual")
314+
})
315+
dsl.Required("provider", "entity_type", "label", "url")
316+
})
317+
318+
// ExternalSourcesAttribute is the DSL attribute for a committee's linked external sources.
319+
func ExternalSourcesAttribute() {
320+
dsl.Attribute("external_sources", dsl.ArrayOf(ExternalSourceType), "External source-labeled entities linked to this committee (e.g. OCG groups or events)", func() {
321+
dsl.MaxLength(50)
322+
dsl.Example([]map[string]interface{}{
323+
{
324+
"provider": "ocg",
325+
"entity_type": "group",
326+
"label": "CNCF Meetup - San Francisco",
327+
"url": "https://community.cncf.io/cncf-meetup-san-francisco/",
328+
},
329+
})
330+
})
331+
}
332+
277333
// EnableVotingAttribute is the DSL attribute for enabling voting.
278334
func EnableVotingAttribute() {
279335
dsl.Attribute("enable_voting", dsl.Boolean, "Whether voting is enabled for this committee", func() {

cmd/committee-api/service/committee_service_response.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ func (s *committeeServicesrvc) convertPayloadToBase(p *committeeservice.CreateCo
6767
// Handle KeyDates if present
6868
base.KeyDates = convertPayloadKeyDatesToModel(p.KeyDates)
6969

70+
// Handle ExternalSources if present
71+
base.ExternalSources = convertPayloadExternalSourcesToModel(p.ExternalSources)
72+
7073
// Handle calendar if present
7174
if p.Calendar != nil {
7275
base.Calendar = model.Calendar{
@@ -143,6 +146,9 @@ func (s *committeeServicesrvc) convertPayloadToUpdateBase(p *committeeservice.Up
143146
// Handle KeyDates if present
144147
base.KeyDates = convertPayloadKeyDatesToModel(p.KeyDates)
145148

149+
// Handle ExternalSources if present
150+
base.ExternalSources = convertPayloadExternalSourcesToModel(p.ExternalSources)
151+
146152
base.JoinMode = p.JoinMode
147153

148154
// Handle calendar if present
@@ -199,6 +205,78 @@ func convertModelKeyDatesToResponse(dates []model.KeyDate) []*committeeservice.K
199205
return result
200206
}
201207

208+
// convertPayloadExternalSourcesToModel converts GOA ExternalSource payloads to domain ExternalSources.
209+
func convertPayloadExternalSourcesToModel(sources []*committeeservice.ExternalSource) []model.ExternalSource {
210+
if sources == nil {
211+
return nil
212+
}
213+
214+
result := make([]model.ExternalSource, 0, len(sources))
215+
for _, src := range sources {
216+
if src == nil {
217+
continue
218+
}
219+
220+
entry := model.ExternalSource{
221+
Provider: src.Provider,
222+
EntityType: src.EntityType,
223+
Label: src.Label,
224+
URL: src.URL,
225+
}
226+
227+
if src.ExternalID != nil {
228+
entry.ExternalID = *src.ExternalID
229+
}
230+
if src.ExternalCategory != nil {
231+
entry.ExternalCategory = *src.ExternalCategory
232+
}
233+
if src.ExternalRegion != nil {
234+
entry.ExternalRegion = *src.ExternalRegion
235+
}
236+
if src.ExternalEventCategory != nil {
237+
entry.ExternalEventCategory = *src.ExternalEventCategory
238+
}
239+
240+
result = append(result, entry)
241+
}
242+
243+
return result
244+
}
245+
246+
// convertModelExternalSourcesToResponse converts domain ExternalSources to GOA response ExternalSources.
247+
func convertModelExternalSourcesToResponse(sources []model.ExternalSource) []*committeeservice.ExternalSource {
248+
if sources == nil {
249+
return nil
250+
}
251+
252+
result := make([]*committeeservice.ExternalSource, 0, len(sources))
253+
for _, src := range sources {
254+
entry := &committeeservice.ExternalSource{
255+
Provider: src.Provider,
256+
EntityType: src.EntityType,
257+
Label: src.Label,
258+
URL: src.URL,
259+
}
260+
261+
if src.ExternalID != "" {
262+
entry.ExternalID = &src.ExternalID
263+
}
264+
if src.ExternalCategory != "" {
265+
entry.ExternalCategory = &src.ExternalCategory
266+
}
267+
if src.ExternalRegion != "" {
268+
entry.ExternalRegion = &src.ExternalRegion
269+
}
270+
if src.ExternalEventCategory != "" {
271+
entry.ExternalEventCategory = &src.ExternalEventCategory
272+
}
273+
274+
result = append(result, entry)
275+
}
276+
277+
return result
278+
}
279+
202280
// convertPayloadToUpdateSettings converts GOA UpdateCommitteeSettingsPayload to CommitteeSettings domain model.
203281
// existing, when non-nil, is used to seed each writer/auditor entry so stored identity fields
204282
// are preserved across PUT requests without the client having to send them.
@@ -271,6 +349,7 @@ func (s *committeeServicesrvc) convertDomainToFullResponse(response *model.Commi
271349
result.Scope = response.Scope
272350
result.Deliverables = response.Deliverables
273351
result.KeyDates = convertModelKeyDatesToResponse(response.KeyDates)
352+
result.ExternalSources = convertModelExternalSourcesToResponse(response.ExternalSources)
274353
if response.SSOGroupName != "" {
275354
result.SsoGroupName = &response.SSOGroupName
276355
}
@@ -358,6 +437,7 @@ func (s *committeeServicesrvc) convertBaseToResponse(base *model.CommitteeBase)
358437
result.Scope = base.Scope
359438
result.Deliverables = base.Deliverables
360439
result.KeyDates = convertModelKeyDatesToResponse(base.KeyDates)
440+
result.ExternalSources = convertModelExternalSourcesToResponse(base.ExternalSources)
361441
if base.SSOGroupName != "" {
362442
result.SsoGroupName = &base.SSOGroupName
363443
}

cmd/committee-api/service/committee_service_response_test.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1531,6 +1531,18 @@ func TestConvertPayloadToBase_CommitteeMetadata(t *testing.T) {
15311531
{Date: "2026-01", Label: "Kickoff"},
15321532
{Date: "2026-06", Label: "Review"},
15331533
},
1534+
ExternalSources: []*committeeservice.ExternalSource{
1535+
{
1536+
Provider: "ocg",
1537+
EntityType: "group",
1538+
Label: "CNCF Meetup - San Francisco",
1539+
URL: "https://community.cncf.io/cncf-meetup-san-francisco/",
1540+
ExternalID: stringPtr("cncf-meetup-san-francisco"),
1541+
ExternalCategory: stringPtr("meetup"),
1542+
ExternalRegion: stringPtr("north-america"),
1543+
ExternalEventCategory: stringPtr("in-person"),
1544+
},
1545+
},
15341546
},
15351547
expected: model.CommitteeBase{
15361548
ProjectUID: "project-123",
@@ -1543,6 +1555,18 @@ func TestConvertPayloadToBase_CommitteeMetadata(t *testing.T) {
15431555
{Date: "2026-01", Label: "Kickoff"},
15441556
{Date: "2026-06", Label: "Review"},
15451557
},
1558+
ExternalSources: []model.ExternalSource{
1559+
{
1560+
Provider: "ocg",
1561+
EntityType: "group",
1562+
Label: "CNCF Meetup - San Francisco",
1563+
URL: "https://community.cncf.io/cncf-meetup-san-francisco/",
1564+
ExternalID: "cncf-meetup-san-francisco",
1565+
ExternalCategory: "meetup",
1566+
ExternalRegion: "north-america",
1567+
ExternalEventCategory: "in-person",
1568+
},
1569+
},
15461570
},
15471571
},
15481572
{
@@ -1589,6 +1613,17 @@ func TestConvertPayloadToUpdateBase_CommitteeMetadata(t *testing.T) {
15891613
KeyDates: []*committeeservice.KeyDate{
15901614
{Date: "2026-02", Label: "Milestone"},
15911615
},
1616+
ExternalSources: []*committeeservice.ExternalSource{
1617+
{
1618+
Provider: "ocg",
1619+
EntityType: "event",
1620+
Label: "CNCF Meetup - Austin",
1621+
URL: "https://community.cncf.io/cncf-meetup-austin/",
1622+
ExternalCategory: stringPtr("meetup"),
1623+
ExternalRegion: stringPtr("north-america"),
1624+
ExternalEventCategory: stringPtr("virtual"),
1625+
},
1626+
},
15921627
},
15931628
expected: model.CommitteeBase{
15941629
UID: "committee-123",
@@ -1601,6 +1636,17 @@ func TestConvertPayloadToUpdateBase_CommitteeMetadata(t *testing.T) {
16011636
KeyDates: []model.KeyDate{
16021637
{Date: "2026-02", Label: "Milestone"},
16031638
},
1639+
ExternalSources: []model.ExternalSource{
1640+
{
1641+
Provider: "ocg",
1642+
EntityType: "event",
1643+
Label: "CNCF Meetup - Austin",
1644+
URL: "https://community.cncf.io/cncf-meetup-austin/",
1645+
ExternalCategory: "meetup",
1646+
ExternalRegion: "north-america",
1647+
ExternalEventCategory: "virtual",
1648+
},
1649+
},
16041650
},
16051651
},
16061652
{
@@ -1649,6 +1695,18 @@ func TestConvertBaseToResponse_CommitteeMetadata(t *testing.T) {
16491695
KeyDates: []model.KeyDate{
16501696
{Date: "2026-01", Label: "Kickoff"},
16511697
},
1698+
ExternalSources: []model.ExternalSource{
1699+
{
1700+
Provider: "ocg",
1701+
EntityType: "group",
1702+
Label: "CNCF Meetup - San Francisco",
1703+
URL: "https://community.cncf.io/cncf-meetup-san-francisco/",
1704+
ExternalID: "cncf-meetup-san-francisco",
1705+
ExternalCategory: "meetup",
1706+
ExternalRegion: "north-america",
1707+
ExternalEventCategory: "in-person",
1708+
},
1709+
},
16521710
},
16531711
expected: &committeeservice.CommitteeBaseWithReadonlyAttributes{
16541712
UID: stringPtr("committee-123"),
@@ -1661,6 +1719,18 @@ func TestConvertBaseToResponse_CommitteeMetadata(t *testing.T) {
16611719
KeyDates: []*committeeservice.KeyDate{
16621720
{Date: "2026-01", Label: "Kickoff"},
16631721
},
1722+
ExternalSources: []*committeeservice.ExternalSource{
1723+
{
1724+
Provider: "ocg",
1725+
EntityType: "group",
1726+
Label: "CNCF Meetup - San Francisco",
1727+
URL: "https://community.cncf.io/cncf-meetup-san-francisco/",
1728+
ExternalID: stringPtr("cncf-meetup-san-francisco"),
1729+
ExternalCategory: stringPtr("meetup"),
1730+
ExternalRegion: stringPtr("north-america"),
1731+
ExternalEventCategory: stringPtr("in-person"),
1732+
},
1733+
},
16641734
Calendar: &struct {
16651735
Public bool
16661736
}{},
@@ -1751,6 +1821,34 @@ func TestValidateCreateCommitteeRequestBody_Metadata(t *testing.T) {
17511821
},
17521822
wantErr: false,
17531823
},
1824+
{
1825+
name: "external_sources entry with invalid url rejected",
1826+
mutate: func(b *server.CreateCommitteeRequestBody) {
1827+
b.ExternalSources = []*server.ExternalSourceRequestBody{
1828+
{
1829+
Provider: stringPtr("ocg"),
1830+
EntityType: stringPtr("group"),
1831+
Label: stringPtr("CNCF Meetup"),
1832+
URL: stringPtr("javascript:alert(1)"),
1833+
},
1834+
}
1835+
},
1836+
wantErr: true,
1837+
},
1838+
{
1839+
name: "external_sources entry with invalid provider rejected",
1840+
mutate: func(b *server.CreateCommitteeRequestBody) {
1841+
b.ExternalSources = []*server.ExternalSourceRequestBody{
1842+
{
1843+
Provider: stringPtr("unknown"),
1844+
EntityType: stringPtr("group"),
1845+
Label: stringPtr("CNCF Meetup"),
1846+
URL: stringPtr("https://community.cncf.io/cncf-meetup-san-francisco/"),
1847+
},
1848+
}
1849+
},
1850+
wantErr: true,
1851+
},
17541852
}
17551853

17561854
for _, tt := range tests {
@@ -1811,6 +1909,34 @@ func TestValidateUpdateCommitteeBaseRequestBody_Metadata(t *testing.T) {
18111909
},
18121910
wantErr: true,
18131911
},
1912+
{
1913+
name: "external_sources entry with invalid url rejected",
1914+
mutate: func(b *server.UpdateCommitteeBaseRequestBody) {
1915+
b.ExternalSources = []*server.ExternalSourceRequestBody{
1916+
{
1917+
Provider: stringPtr("ocg"),
1918+
EntityType: stringPtr("group"),
1919+
Label: stringPtr("CNCF Meetup"),
1920+
URL: stringPtr("javascript:alert(1)"),
1921+
},
1922+
}
1923+
},
1924+
wantErr: true,
1925+
},
1926+
{
1927+
name: "external_sources entry with invalid provider rejected",
1928+
mutate: func(b *server.UpdateCommitteeBaseRequestBody) {
1929+
b.ExternalSources = []*server.ExternalSourceRequestBody{
1930+
{
1931+
Provider: stringPtr("unknown"),
1932+
EntityType: stringPtr("group"),
1933+
Label: stringPtr("CNCF Meetup"),
1934+
URL: stringPtr("https://community.cncf.io/cncf-meetup-san-francisco/"),
1935+
},
1936+
}
1937+
},
1938+
wantErr: true,
1939+
},
18141940
}
18151941

18161942
for _, tt := range tests {

docs/indexer-contract.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ These fields are indexed and queryable via `filters` or `cel_filter` in the quer
5959
| `scope` | []string (optional) | Committee scope bullet points |
6060
| `deliverables` | []string (optional) | Committee deliverables bullet points |
6161
| `key_dates` | []object (optional) | Committee key-dates timeline, each with `date` (`YYYY-MM`) and `label` |
62+
| `external_sources` | []object (optional) | Source-labeled external entities linked to the committee (e.g. OCG groups/events), each with `provider`, `entity_type`, `label`, `url`, and optional `external_id`, `external_category`, `external_region`, `external_event_category` |
6263
| `total_members` | int | Current total member count |
6364
| `total_voting_repos` | int | Current total voting repos count |
6465
| `has_mailing_list` | bool | Whether a related mailing list exists |

0 commit comments

Comments
 (0)