From a7e017971afff4623c295115b6b2c277a41dc75a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sun, 26 Jul 2026 14:08:02 +0200 Subject: [PATCH] WIP Group contributions by URL --- app/models/contribution.js | 9 +++ app/services/kredits.js | 68 ++++++++++++++++++ app/utils/contribution-grouping-key.js | 17 +++++ tests/fixtures/contributions.js | 8 +-- tests/unit/services/kredits-test.js | 72 +++++++++++++++++++ .../utils/contribution-grouping-key-test.js | 48 +++++++++++++ 6 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 app/utils/contribution-grouping-key.js create mode 100644 tests/unit/utils/contribution-grouping-key-test.js diff --git a/app/models/contribution.js b/app/models/contribution.js index f57bbed..21f862a 100644 --- a/app/models/contribution.js +++ b/app/models/contribution.js @@ -2,6 +2,8 @@ import EmberObject, { computed } from '@ember/object'; import { isEmpty, isPresent } from '@ember/utils'; import moment from 'moment'; +import contributionGroupingKey from 'kredits-web/utils/contribution-grouping-key'; + export default EmberObject.extend({ // Contract id: null, @@ -44,6 +46,13 @@ export default EmberObject.extend({ return isPresent(this.pendingTx); }), + // Stable key shared by all per-contributor contributions created for the + // same issue/pull request (derived from `url`). Manual contributions without + // a URL return null and are treated as singletons (not grouped). + groupId: computed('url', function() { + return contributionGroupingKey(this); + }), + serialize () { return JSON.stringify(this); } diff --git a/app/services/kredits.js b/app/services/kredits.js index 641af86..eaec074 100644 --- a/app/services/kredits.js +++ b/app/services/kredits.js @@ -227,6 +227,74 @@ export default Service.extend({ return kreditsByContributor; }), + // Groups contributions that were created together for the same + // issue/pull request (one contribution per contributor). The grouping key + // is the contribution's `url` (see utils/contribution-grouping-key). + // Manual contributions without a URL each form their own single-item group + // (they are never collapsed together, since `null` is not a real key). + // + // Each group is an EmberObject with: groupId, items, contributors, + // contributorIds, totalAmount, kind, description, url, date, isGrouped + // (true when more than one contribution shares the key). + contributionsGrouped: computed('contributions.[]', function() { + const groupsMap = new Map(); + const singletons = []; + + this.contributions.forEach(c => { + const groupId = c.groupId; + if (isEmpty(groupId)) { + singletons.push(c); + } else if (groupsMap.has(groupId)) { + groupsMap.get(groupId).items.push(c); + } else { + groupsMap.set(groupId, { groupId, items: [c] }); + } + }); + + const groups = [ + ...groupsMap.values(), + ...singletons.map(c => ({ groupId: null, items: [c] })) + ]; + + return groups.map(group => { + const items = group.items; + const first = items.firstObject; + const contributorIds = items.mapBy('contributorId').uniq(); + const contributors = contributorIds + .map(id => this.contributors.findBy('id', id)) + .filter(Boolean); + const totalAmount = items + .mapBy('amount') + .reduce((a, b) => a + (b || 0), 0); + + return EmberObject.create({ + groupId: group.groupId, + items, + contributors, + contributorIds, + totalAmount, + kind: first ? first.kind : null, + description: first ? first.description : null, + url: first ? first.url : null, + date: first ? first.date : null, + isGrouped: items.length > 1 + }); + }); + }), + + // Returns the other contributions that share a grouping key with the given + // contribution (i.e. the co-contributors' contributions). Returns an empty + // array when the contribution has no group (manual, no URL) or has no + // siblings. + siblingContributions (contribution) { + if (!contribution) return []; + const groupId = contribution.groupId; + if (isEmpty(groupId)) return []; + const group = this.contributionsGrouped.findBy('groupId', groupId); + if (!group) return []; + return group.items.filter(c => c !== contribution); + }, + contributionsUnconfirmed: computed('contributions.[]', 'currentBlock', function() { return this.contributions .filter(c => c.confirmedAt > this.currentBlock); diff --git a/app/utils/contribution-grouping-key.js b/app/utils/contribution-grouping-key.js new file mode 100644 index 0000000..596ffb9 --- /dev/null +++ b/app/utils/contribution-grouping-key.js @@ -0,0 +1,17 @@ +import { isEmpty } from '@ember/utils'; + +// +// Derives a stable grouping key for a contribution, so that contributions +// created for the same issue/pull request (one per contributor) can be +// linked together in the UI. +// +// The bot writes the exact same `url` string (the html_url of the issue/PR) +// into every per-contributor IPFS document for a given piece of work, so the +// top-level `url` field on the model is sufficient as a grouping key. Manual +// contributions without a URL return null and are treated as singletons. +// +export default function contributionGroupingKey (contribution) { + let url = contribution ? contribution.url : null; + if (isEmpty(url)) return null; + return url; +} diff --git a/tests/fixtures/contributions.js b/tests/fixtures/contributions.js index 5a809f0..3af9620 100644 --- a/tests/fixtures/contributions.js +++ b/tests/fixtures/contributions.js @@ -5,14 +5,14 @@ import processContributionData from 'kredits-web/utils/process-contribution-data const items = []; const data = [ - { id: 1, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'dev' }, + { id: 1, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'dev', url: 'https://github.com/67P/kredits-contracts/pull/196' }, { id: 2, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 5000, kind: 'ops' }, - { id: 3, contributorId: 2, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'ops' }, + { id: 3, contributorId: 2, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'ops', url: 'https://github.com/67P/kredits-contracts/pull/196' }, { id: 4, contributorId: 2, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'docs' }, { id: 5, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 5000, kind: 'design' }, { id: 6, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: true, amount: 500, kind: 'dev' }, - { id: 7, contributorId: 3, confirmedAt: 2000, claimed: false, vetoed: false, amount: 5000, kind: 'dev' }, - { id: 8, contributorId: 1, confirmedAt: 2000, claimed: false, vetoed: false, amount: 1500, kind: 'community' }, + { id: 7, contributorId: 3, confirmedAt: 2000, claimed: false, vetoed: false, amount: 5000, kind: 'dev', url: 'https://gitea.kosmos.org/kredits/kredits-web/issues/231' }, + { id: 8, contributorId: 1, confirmedAt: 2000, claimed: false, vetoed: false, amount: 1500, kind: 'community', url: 'https://gitea.kosmos.org/kredits/kredits-web/issues/231' }, { id: 9, contributorId: 3, confirmedAt: 2000, claimed: false, vetoed: true, amount: 1500, kind: 'docs' }, ]; diff --git a/tests/unit/services/kredits-test.js b/tests/unit/services/kredits-test.js index 16b5ca4..6087acd 100644 --- a/tests/unit/services/kredits-test.js +++ b/tests/unit/services/kredits-test.js @@ -60,6 +60,78 @@ module('Unit | Service | kredits', function(hooks) { assert.equal(service.contributorsSorted[1].name, 'Manuel', 'sorts by name'); }); + test('#contributionsGrouped groups contributions sharing a url', function(assert) { + let service = this.owner.lookup('service:kredits'); + service.set('contributors', contributors); + service.set('contributions', contributions); + + const grouped = service.contributionsGrouped; + + // 9 contributions, with ids 1+3 sharing a url and ids 7+8 sharing a url, + // so 7 groups total (2 grouped + 5 singletons). + assert.equal(grouped.length, 7, 'produces one group per unique key plus singletons'); + + const ghGroup = grouped.findBy('groupId', 'https://github.com/67P/kredits-contracts/pull/196'); + assert.ok(ghGroup, 'github url group exists'); + assert.ok(ghGroup.isGrouped, 'github group is flagged as grouped'); + assert.equal(ghGroup.items.length, 2, 'github group has two contributions'); + assert.deepEqual(ghGroup.contributorIds, [1, 2], 'github group lists both contributor ids'); + assert.equal(ghGroup.contributors.length, 2, 'github group resolves both contributors'); + assert.equal(ghGroup.totalAmount, 3000, 'github group sums amounts (1500 + 1500)'); + assert.equal(ghGroup.kind, 'dev', 'github group takes kind from first item'); + assert.equal(ghGroup.url, 'https://github.com/67P/kredits-contracts/pull/196'); + + const giteaGroup = grouped.findBy('groupId', 'https://gitea.kosmos.org/kredits/kredits-web/issues/231'); + assert.ok(giteaGroup, 'gitea url group exists'); + assert.ok(giteaGroup.isGrouped, 'gitea group is flagged as grouped'); + assert.deepEqual(giteaGroup.contributorIds, [3, 1], 'gitea group lists both contributor ids'); + assert.equal(giteaGroup.totalAmount, 6500, 'gitea group sums amounts (5000 + 1500)'); + }); + + test('#contributionsGrouped treats manual contributions without url as singletons', function(assert) { + let service = this.owner.lookup('service:kredits'); + service.set('contributors', contributors); + service.set('contributions', contributions); + + const grouped = service.contributionsGrouped; + const singletons = grouped.filterBy('isGrouped', false); + + // 5 contributions without a url: ids 2, 4, 5, 6, 9 + assert.equal(singletons.length, 5, 'one singleton group per url-less contribution'); + singletons.forEach(g => { + assert.equal(g.items.length, 1, 'singleton group has exactly one item'); + assert.notOk(g.groupId, 'singleton group has a null/empty groupId'); + }); + }); + + test('#siblingContributions returns co-contributor contributions for the same url', function(assert) { + let service = this.owner.lookup('service:kredits'); + service.set('contributors', contributors); + service.set('contributions', contributions); + + const c1 = contributions.findBy('id', 1); + const siblings = service.siblingContributions(c1); + assert.equal(siblings.length, 1, 'contribution 1 has one sibling'); + assert.equal(siblings[0].id, 3, 'the sibling is contribution 3'); + }); + + test('#siblingContributions returns empty for manual contributions', function(assert) { + let service = this.owner.lookup('service:kredits'); + service.set('contributors', contributors); + service.set('contributions', contributions); + + const c2 = contributions.findBy('id', 2); + assert.deepEqual(service.siblingContributions(c2), [], 'no siblings for url-less contribution'); + }); + + test('#siblingContributions returns empty for null contribution', function(assert) { + let service = this.owner.lookup('service:kredits'); + service.set('contributors', contributors); + service.set('contributions', contributions); + + assert.deepEqual(service.siblingContributions(null), [], 'no siblings for null'); + }); + test('#kreditsByContributor ignores unconfirmed contributions for contributors that are not loaded', function(assert) { let service = this.owner.lookup('service:kredits'); service.set('contributors', contributors); diff --git a/tests/unit/utils/contribution-grouping-key-test.js b/tests/unit/utils/contribution-grouping-key-test.js new file mode 100644 index 0000000..33e720e --- /dev/null +++ b/tests/unit/utils/contribution-grouping-key-test.js @@ -0,0 +1,48 @@ +import { module, test } from 'qunit'; +import EmberObject from '@ember/object'; +import contributionGroupingKey from 'kredits-web/utils/contribution-grouping-key'; + +function makeContribution (url) { + return EmberObject.create({ url }); +} + +module('Unit | Utils | contribution-grouping-key', function() { + + test('returns the url when present', function(assert) { + const c = makeContribution('https://github.com/67P/kredits-contracts/pull/196'); + assert.equal(contributionGroupingKey(c), 'https://github.com/67P/kredits-contracts/pull/196'); + }); + + test('returns the same key for identical github urls', function(assert) { + const a = makeContribution('https://github.com/67P/kredits-contracts/pull/196'); + const b = makeContribution('https://github.com/67P/kredits-contracts/pull/196'); + assert.equal(contributionGroupingKey(a), contributionGroupingKey(b)); + }); + + test('returns the same key for identical gitea urls', function(assert) { + const a = makeContribution('https://gitea.kosmos.org/kredits/kredits-web/issues/231'); + const b = makeContribution('https://gitea.kosmos.org/kredits/kredits-web/issues/231'); + assert.equal(contributionGroupingKey(a), contributionGroupingKey(b)); + }); + + test('returns different keys for different urls', function(assert) { + const a = makeContribution('https://github.com/67P/kredits-contracts/pull/196'); + const b = makeContribution('https://github.com/67P/kredits-contracts/pull/197'); + assert.notEqual(contributionGroupingKey(a), contributionGroupingKey(b)); + }); + + test('returns null when url is empty', function(assert) { + const c = makeContribution(''); + assert.equal(contributionGroupingKey(c), null); + }); + + test('returns null when url is null/undefined', function(assert) { + assert.equal(contributionGroupingKey(makeContribution(null)), null); + assert.equal(contributionGroupingKey(makeContribution(undefined)), null); + }); + + test('returns null when contribution is null/undefined', function(assert) { + assert.equal(contributionGroupingKey(null), null); + assert.equal(contributionGroupingKey(undefined), null); + }); +});