Skip to content

Commit 787035a

Browse files
committed
Add first line of commit messages to each pushlog entry
Each pushlog entry now also contains the first line of the corresponding changeset. The first line is additionally limited to 100 characters.
1 parent e058d40 commit 787035a

File tree

15 files changed

+264
-63
lines changed

15 files changed

+264
-63
lines changed

docs/de/usage/assets/csv-export.png

35.4 KB
Loading

docs/de/usage/index.md

+6
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,9 @@ Dabei wird diese Information nicht inhaltlich aus dem Commit ermittelt, sondern
99
Diese Information findet sich dann in der Detail-Ansicht eines Commits als der `Mitwirkende` in der Kategorie `Pushed by`.
1010

1111
![Pushed by](assets/pushed-by.png)
12+
13+
Zusätzlich ist es möglich alle Pushlog Einträge eines Repositories als CSV-Datei zu exportieren.
14+
Dieses CSV enthält die ID des Pushlogs, die Commit Id, den Benutzernamen vom User der den Commit zu erst gepushed hat, den Zeitstempel wann der Commit beim Server eingegangen ist und die erste Zeile der Commit-Nachricht.
15+
Der Downloadlink für den CSV-Export befindet sich in der Informationsseite eines Repositories.
16+
17+
![Downloadlink für den CSV-Export](./assets/csv-export.png)

docs/en/usage/assets/csv-export.png

39.8 KB
Loading

docs/en/usage/index.md

+6
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,9 @@ This information is not determined from the content of the commit, but the user
99
You can find the stored `pusher` in the detail view of a commit as the `contributor` in the `Pushed by` category.
1010

1111
![Pushed by](assets/pushed-by.png)
12+
13+
It is also possible to export all push log entries of a repository as a CSV file.
14+
This CSV contains the ID of the push log, the commit ID, the username of the user who first pushed the commit, the timestamp when the server received the commit and the first line of the commit message.
15+
The download link for the CSV export can be found on the information page of a repository.
16+
17+
![Download link of the CSV export](./assets/csv-export.png)

gradle/changelog/commit-message.yaml

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
- type: added
2+
description: The first line of each commit message is now added to the pushlog csv export

src/main/java/sonia/scm/pushlog/PushlogEntry.java

+6-2
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,17 @@
3131
public class PushlogEntry {
3232

3333
private long pushlogId;
34-
3534
private String username;
36-
3735
private Instant contributionTime;
36+
private String description;
3837

3938
public PushlogEntry(String username, Instant contributionTime) {
39+
this(username, contributionTime, null);
40+
}
41+
42+
public PushlogEntry(String username, Instant contributionTime, String description) {
4043
this.username = username;
4144
this.contributionTime = contributionTime;
45+
this.description = description;
4246
}
4347
}

src/main/java/sonia/scm/pushlog/PushlogHook.java

+34-5
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,15 @@
3232
import sonia.scm.security.Role;
3333

3434
import java.time.Instant;
35-
import java.util.ArrayList;
36-
import java.util.Collection;
35+
import java.util.HashMap;
36+
import java.util.Map;
3737

3838
@Slf4j
3939
@Extension
4040
@EagerSingleton
4141
public class PushlogHook {
4242

43+
private static final int MAXIMUM_DESCRIPTION_LENGTH = 100;
4344
private final PushlogManager pushlogManager;
4445

4546
@Inject
@@ -82,10 +83,38 @@ private void handlePushEvent(String username, RepositoryHookEvent event) {
8283

8384
private void handlePush(String username, Repository repository,
8485
Instant creationDate, Iterable<Changeset> changesets) {
85-
Collection<String> revisions = new ArrayList<>();
86+
Map<String, PushlogEntry> revisionWithPushlogs = new HashMap<>();
8687
for (Changeset c : changesets) {
87-
revisions.add(c.getId());
88+
revisionWithPushlogs.put(
89+
c.getId(),
90+
new PushlogEntry(username, creationDate, limitChangesetDescription(c.getDescription()))
91+
);
8892
}
89-
pushlogManager.store(new PushlogEntry(username, creationDate), repository, revisions);
93+
pushlogManager.storeRevisionEntryMap(revisionWithPushlogs, repository);
94+
}
95+
96+
//Take the first line of the description and limit it to the maximum description length
97+
private String limitChangesetDescription(String description) {
98+
if (description == null) {
99+
return null;
100+
}
101+
102+
int lineLimit = 0;
103+
while (lineLimit < description.length()) {
104+
if (isNewline(description.charAt(lineLimit))) {
105+
break;
106+
}
107+
++lineLimit;
108+
}
109+
110+
if (lineLimit > MAXIMUM_DESCRIPTION_LENGTH) {
111+
return description.substring(0, MAXIMUM_DESCRIPTION_LENGTH) + "...";
112+
}
113+
114+
return description.substring(0, lineLimit);
115+
}
116+
117+
private boolean isNewline(char c) {
118+
return c == '\n' || c == '\r';
90119
}
91120
}

src/main/java/sonia/scm/pushlog/PushlogManager.java

+29-7
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
import sonia.scm.store.StoreException;
2626

2727
import java.util.Collection;
28-
28+
import java.util.Map;
2929
import java.util.Optional;
3030
import java.util.function.Consumer;
3131

@@ -49,8 +49,7 @@ public void store(PushlogEntry entry, Repository repository, Collection<String>
4949
log.debug("store pushlog for repository {}", repository);
5050
try (QueryableMutableStore<PushlogEntry> store = storeFactory.getMutable(repository.getId())) {
5151
store.transactional(() -> {
52-
Long maxId = store.query(PushlogEntryQueryFields.REPOSITORY_ID.eq(repository.getId()))
53-
.max(PushlogEntryQueryFields.PUSHLOGID);
52+
Long maxId = getMaxId(store, repository);
5453
entry.setPushlogId(maxId == null ? 1 : maxId + 1);
5554
revisions.forEach(
5655
revision -> {
@@ -66,6 +65,29 @@ public void store(PushlogEntry entry, Repository repository, Collection<String>
6665
log.debug("stored {} pushlogs for repository {}", revisions.size(), repository);
6766
}
6867

68+
public void storeRevisionEntryMap(Map<String, PushlogEntry> revisionsWithPushlogs, Repository repository) {
69+
log.debug("store pushlog for repository {}", repository);
70+
try (QueryableMutableStore<PushlogEntry> store = storeFactory.getMutable(repository.getId())) {
71+
store.transactional(() -> {
72+
Long maxId = getMaxId(store, repository);
73+
revisionsWithPushlogs.forEach((revision, entry) -> {
74+
entry.setPushlogId(maxId == null ? 1 : maxId + 1);
75+
if (store.getOptional(revision).isEmpty()) {
76+
store.put(revision, entry);
77+
}
78+
});
79+
return true;
80+
}
81+
);
82+
}
83+
log.debug("stored {} pushlogs for repository {}", revisionsWithPushlogs.size(), repository);
84+
}
85+
86+
private Long getMaxId(QueryableStore<PushlogEntry> store, Repository repository) {
87+
return store.query(PushlogEntryQueryFields.REPOSITORY_ID.eq(repository.getId()))
88+
.max(PushlogEntryQueryFields.PUSHLOGID);
89+
}
90+
6991
public Optional<PushlogEntry> get(Repository repository, String id) {
7092
try (QueryableMutableStore<PushlogEntry> store = storeFactory.getMutable(repository.getId())) {
7193
return store.getOptional(id);
@@ -76,9 +98,9 @@ public Optional<PushlogEntry> get(Repository repository, String id) {
7698
* Returns a map of all pushlogs from a {@link Repository} with the revision as its key and {@link PushlogEntry} entries.
7799
*
78100
* @param repository Repository from where the entries are fetched from.
79-
* @param consumer Consumer function receiving {@link QueryableStore.Result} objects. Each of them contains
80-
* a {@link String} key representing the revision and the {@link PushlogEntry} value.
81-
* @param order Whether the result is ordered in a descending or ascending fashion.
101+
* @param consumer Consumer function receiving {@link QueryableStore.Result} objects. Each of them contains
102+
* a {@link String} key representing the revision and the {@link PushlogEntry} value.
103+
* @param order Whether the result is ordered in a descending or ascending fashion.
82104
*/
83105
public void doExport(Repository repository, Consumer<QueryableStore.Result<PushlogEntry>> consumer, QueryableStore.Order order) {
84106
log.debug("start export for repository {} with order {}", repository, order);
@@ -88,7 +110,7 @@ public void doExport(Repository repository, Consumer<QueryableStore.Result<Pushl
88110
.withIds()
89111
.orderBy(PushlogEntryQueryFields.PUSHLOGID, order)
90112
.forEach(consumer);
91-
} catch(Exception e) {
113+
} catch (Exception e) {
92114
throw new StoreException(
93115
format("An exception occurred while trying to export pushlog entries from repository %s", repository), e);
94116
}

src/main/java/sonia/scm/pushlog/export/api/csv/CsvPushlogEntry.java

+4-2
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,15 @@
2828
* @param revision {@link String}
2929
* @param username {@link String}
3030
* @param timestamp {@link ZonedDateTime}
31+
* @param description {@link String}
3132
*/
3233
@JsonPropertyOrder({"pushlogId", "revision", "username", "timestamp"})
3334
public record CsvPushlogEntry(
3435
long pushlogId,
3536
String revision,
3637
String username,
37-
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss.SSS 'UTC'Z") ZonedDateTime timestamp) {
38+
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss.SSS 'UTC'Z") ZonedDateTime timestamp,
39+
String description) {
3840

39-
public static final String HEADER = "PushlogId,Revision,Username,Timestamp";
41+
public static final String HEADER = "PushlogId,Revision,Username,Timestamp,Description";
4042
}

src/main/java/sonia/scm/pushlog/export/api/csv/CsvResource.java

+3-2
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,9 @@ private CsvPushlogEntry from(QueryableStore.Result<PushlogEntry> result) {
124124
result.getEntity().getPushlogId(),
125125
result.getId(),
126126
result.getEntity().getUsername(),
127-
result.getEntity().getContributionTime() != null ?
128-
result.getEntity().getContributionTime().atZone(ZONE_ID) : null);
127+
result.getEntity().getContributionTime() != null ? result.getEntity().getContributionTime().atZone(ZONE_ID) : null,
128+
result.getEntity().getDescription()
129+
);
129130
}
130131

131132
@GET

src/main/java/sonia/scm/pushlog/update/MoveToQueryableUpdateStep.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ public void doUpdate(RepositoryUpdateContext repositoryUpdateContext) {
7373
.map(changeset -> new QueryableMaintenanceStore.Row<>(
7474
new String[]{repositoryUpdateContext.getRepositoryId()},
7575
String.valueOf(changeset),
76-
new PushlogEntry(entry.getId(), entry.getUsername(), contributionTime)
76+
new PushlogEntry(entry.getId(), entry.getUsername(), contributionTime, null)
7777
));
7878
})
7979
));

src/test/java/sonia/scm/pushlog/PushlogDetailsDtoEmbeddedEnricherTest.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ class PushlogDetailsDtoEmbeddedEnricherTest {
6565
void shouldAppendEmbeddedPushlogDetailsDtoWithTimestampAndDisplayUser() {
6666
when(context.oneRequireByType(Repository.class)).thenReturn(repository);
6767
when(context.oneRequireByType(Changeset.class)).thenReturn(changeset);
68-
PushlogEntry pushlogEntry = new PushlogEntry(1, "username", Instant.now());
68+
PushlogEntry pushlogEntry = new PushlogEntry(1, "username", Instant.now(), null);
6969
when(pushlogManager.get(repository, "42")).thenReturn(of(pushlogEntry));
7070
when(userDisplayManager.get(pushlogEntry.getUsername())).thenReturn(of(displayUser));
7171

src/test/java/sonia/scm/pushlog/PushlogHookTest.java

+67-16
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,8 @@
3333

3434
import java.time.Instant;
3535
import java.util.Arrays;
36+
import java.util.Map;
3637

37-
import static org.mockito.ArgumentMatchers.argThat;
38-
import static org.mockito.ArgumentMatchers.eq;
3938
import static org.mockito.Mockito.mockStatic;
4039
import static org.mockito.Mockito.verify;
4140
import static org.mockito.Mockito.verifyNoInteractions;
@@ -62,6 +61,7 @@ class PushlogHookTest {
6261
private Changeset changeset2;
6362

6463
private PushlogHook pushlogHook;
64+
private final Instant creationDate = Instant.now();
6565

6666
@BeforeEach
6767
public void setUp() {
@@ -127,11 +127,73 @@ void testOnEventWithoutChangesets() {
127127
}
128128

129129
@Test
130-
void testOnEventSuccessfulPush() {
130+
void testOnEventSuccessfulPushWithOneLineCommitMessage() {
131+
when(changeset1.getDescription()).thenReturn("Commit Message");
132+
when(changeset2.getDescription()).thenReturn("Second commit message");
133+
executeSuccessfulPushTestCases(() -> {
134+
pushlogHook.onEvent(event);
135+
verify(pushlogManager).storeRevisionEntryMap(
136+
Map.of(
137+
"rev1", new PushlogEntry("testUser", creationDate, "Commit Message"),
138+
"rev2", new PushlogEntry("testUser", creationDate, "Second commit message")
139+
),
140+
repository
141+
);
142+
});
143+
}
144+
145+
@Test
146+
void testOnEventSuccessfulPushWithLimitedOneLineCommitMessage() {
147+
when(changeset1.getDescription()).thenReturn("a".repeat(100));
148+
when(changeset2.getDescription()).thenReturn("b".repeat(101));
149+
executeSuccessfulPushTestCases(() -> {
150+
pushlogHook.onEvent(event);
151+
verify(pushlogManager).storeRevisionEntryMap(
152+
Map.of(
153+
"rev1", new PushlogEntry("testUser", creationDate, "a".repeat(100)),
154+
"rev2", new PushlogEntry("testUser", creationDate, "b".repeat(100) + "...")
155+
),
156+
repository
157+
);
158+
});
159+
}
160+
161+
@Test
162+
void testOnEventSuccessfulPushWithMultiLineCommitMessage() {
163+
when(changeset1.getDescription()).thenReturn("First Line\nSecond Line");
164+
when(changeset2.getDescription()).thenReturn("1. Line\n2. Line\n3. Line");
165+
executeSuccessfulPushTestCases(() -> {
166+
pushlogHook.onEvent(event);
167+
verify(pushlogManager).storeRevisionEntryMap(
168+
Map.of(
169+
"rev1", new PushlogEntry("testUser", creationDate, "First Line"),
170+
"rev2", new PushlogEntry("testUser", creationDate, "1. Line")
171+
),
172+
repository
173+
);
174+
});
175+
}
176+
177+
@Test
178+
void testOnEventSuccessfulPushWithLimitedMultiLineCommitMessage() {
179+
when(changeset1.getDescription()).thenReturn("First Line\n" + "a".repeat(100));
180+
when(changeset2.getDescription()).thenReturn("b".repeat(101) + "\nSecond Line");
181+
executeSuccessfulPushTestCases(() -> {
182+
pushlogHook.onEvent(event);
183+
verify(pushlogManager).storeRevisionEntryMap(
184+
Map.of(
185+
"rev1", new PushlogEntry("testUser", creationDate, "First Line"),
186+
"rev2", new PushlogEntry("testUser", creationDate, "b".repeat(100) + "...")
187+
),
188+
repository
189+
);
190+
});
191+
}
192+
193+
private void executeSuccessfulPushTestCases(Runnable testCase) {
131194
when(subject.hasRole(Role.USER)).thenReturn(true);
132195
when(subject.getPrincipal()).thenReturn("testUser");
133196
when(event.getRepository()).thenReturn(repository);
134-
Instant creationDate = Instant.now();
135197
when(event.getCreationDate()).thenReturn(creationDate);
136198
when(event.getContext()).thenReturn(context);
137199
when(context.getChangesetProvider()).thenReturn(changesetBuilder);
@@ -142,18 +204,7 @@ void testOnEventSuccessfulPush() {
142204

143205
try (MockedStatic<SecurityUtils> securityUtilsMock = mockStatic(SecurityUtils.class)) {
144206
securityUtilsMock.when(SecurityUtils::getSubject).thenReturn(subject);
145-
146-
pushlogHook.onEvent(event);
147-
verify(pushlogManager).store(
148-
argThat(entry ->
149-
entry.getUsername().equals("testUser") &&
150-
entry.getContributionTime().equals(creationDate)
151-
),
152-
eq(repository),
153-
argThat(revisions ->
154-
revisions.containsAll(Arrays.asList("rev1", "rev2"))
155-
)
156-
);
207+
testCase.run();
157208
}
158209
}
159210
}

src/test/java/sonia/scm/pushlog/PushlogManagerTest.java

+44
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,50 @@ void shouldNotReplaceExistingEntries(PushlogEntryStoreFactory storeFactory) {
8282
assertThat(all.get("r2")).isEqualTo(newEntry);
8383
}
8484

85+
@Test
86+
void shouldStoreNewEntriesWithRevisionMap(PushlogEntryStoreFactory storeFactory) {
87+
PushlogEntry firstEntry = new PushlogEntry("trillian", Instant.now(), "Commit Message");
88+
PushlogEntry secondEntry = new PushlogEntry("trillian", Instant.now(), "Second Message");
89+
manager.storeRevisionEntryMap(Map.of("r1", firstEntry, "r2", secondEntry), repository);
90+
91+
Map<String, PushlogEntry> all = storeFactory.getMutable(repository).getAll();
92+
assertThat(all).hasSize(2);
93+
assertThat(all.get("r1")).isEqualTo(firstEntry);
94+
assertThat(all.get("r2")).isEqualTo(secondEntry);
95+
assertThat(all.get("r1").getPushlogId()).isEqualTo(1);
96+
assertThat(all.get("r2").getPushlogId()).isEqualTo(1);
97+
}
98+
99+
@Test
100+
void shouldIncrementPushlogIdWithRevisionMap(PushlogEntryStoreFactory storeFactory) {
101+
manager.storeRevisionEntryMap(
102+
Map.of("r1", new PushlogEntry("trillian", Instant.now(), "Commit Message")),
103+
repository
104+
);
105+
manager.storeRevisionEntryMap(
106+
Map.of("r2", new PushlogEntry("trillian", Instant.now(), "Commit Message")),
107+
repository
108+
);
109+
110+
Map<String, PushlogEntry> all = storeFactory.getMutable(repository).getAll();
111+
assertThat(all.get("r1").getPushlogId()).isEqualTo(1);
112+
assertThat(all.get("r2").getPushlogId()).isEqualTo(2);
113+
}
114+
115+
@Test
116+
void shouldNotReplaceExistingEntriesWithRevisionMap(PushlogEntryStoreFactory storeFactory) {
117+
PushlogEntry existingEntry = new PushlogEntry("trillian", Instant.now(), "Commit Message");
118+
manager.storeRevisionEntryMap(Map.of("r1", existingEntry), repository);
119+
120+
PushlogEntry newEntry = new PushlogEntry("arthur", Instant.now(), "New Message");
121+
manager.storeRevisionEntryMap(Map.of("r1", newEntry, "r2", newEntry), repository);
122+
123+
Map<String, PushlogEntry> all = storeFactory.getMutable(repository).getAll();
124+
assertThat(all).hasSize(2);
125+
assertThat(all.get("r1")).isEqualTo(existingEntry);
126+
assertThat(all.get("r2")).isEqualTo(newEntry);
127+
}
128+
85129
@Test
86130
void shouldSortContributionsById(PushlogEntryStoreFactory storeFactory) {
87131
int entries = 5;

0 commit comments

Comments
 (0)