This is an automated email from the ASF dual-hosted git repository.
mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new b458586968 additional fields for git input transform. fixes #8347
(#8348)
b458586968 is described below
commit b458586968d6786bd37ad4a3293329e0c9eee8bd
Author: Bart Maertens <[email protected]>
AuthorDate: Sun Sep 13 16:07:21 2026 +0200
additional fields for git input transform. fixes #8347 (#8348)
---
.../ROOT/pages/pipeline/transforms/gitinput.adoc | 64 ++++--
.../0112-git-input-github-pull-requests.hpl | 4 +-
.../git/pipeline/transforms/gitinput/GitInput.java | 2 +-
.../pipeline/transforms/gitinput/GitInputMeta.java | 23 +-
.../hop/git/provider/BitbucketResourceClient.java | 97 ++++----
.../hop/git/provider/GitHubResourceClient.java | 95 ++++----
.../apache/hop/git/provider/GitInputFields.java | 256 ++++++++++++++++-----
.../hop/git/provider/GitIssueActivityMapper.java | 73 +++---
.../org/apache/hop/git/provider/GitJsonLists.java | 81 +++++++
.../hop/git/provider/GitLabResourceClient.java | 83 +++----
.../apache/hop/git/provider/GitResourceRecord.java | 137 +++++------
.../hop/git/provider/GiteaResourceClient.java | 86 +++----
.../hop/git/provider/LocalGitResourceClient.java | 104 +++++----
.../transforms/git-input-github-pull-requests.hpl | 26 ++-
.../transforms/gitinput/GitInputMetaTest.java | 66 +++++-
.../pipeline/transforms/gitinput/GitInputTest.java | 4 +-
.../BitbucketResourceClientWireMockTest.java | 9 +-
.../hop/git/provider/GitHubResourceClientTest.java | 11 +-
.../provider/GitHubResourceClientWireMockTest.java | 86 ++++++-
.../apache/hop/git/provider/GitJsonListsTest.java | 82 +++++++
.../provider/GitLabResourceClientWireMockTest.java | 10 +-
.../provider/GiteaResourceClientWireMockTest.java | 4 +-
.../git/provider/LocalGitResourceClientTest.java | 90 +++++++-
23 files changed, 1070 insertions(+), 423 deletions(-)
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/gitinput.adoc
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/gitinput.adoc
index 4b179526aa..061456f420 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/gitinput.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/gitinput.adoc
@@ -86,7 +86,13 @@ Only used when the source is `REMOTE`. For a local
repository the owner and name
== Output fields
-Every row has the same fields whatever the provider or resource type.
+The row layout is chosen per **Resource type**, not shared by all of them. A
single layout would have to be the union of everything any type can report, so
a commit row would carry empty label and assignee columns and an issue row
would carry empty branch and merge columns.
+
+Within a type the layout is identical across providers, so a pipeline written
against GitHub keeps working when it is pointed at GitLab or Bitbucket. A field
a given provider does not report arrives as an empty value, not as a missing
column.
+
+Changing **Resource type** changes the fields the transform emits, so re-open
the dialog after changing it to let the downstream transforms pick the new
layout up.
+
+=== Fields every type reports
[options="header"]
|===
@@ -95,19 +101,49 @@ Every row has the same fields whatever the provider or
resource type.
|entity_type|String|The resource type that produced the row, for example
`commits` or `pull_requests`.
|repo_owner, repo_name|String|The repository the row belongs to.
|id|String|Provider identifier: a GitHub node id, a GitLab id, or the commit
SHA.
-|number|Integer|Issue or pull request number. GitLab uses the issue **iid**.
-|title|String|Issue or pull request title, the commit message subject, or the
changed file path for `COMMIT_FILES`.
-|state|String|Issue or pull request state, or the change type for
`COMMIT_FILES`.
-|author|String|Commit author, issue reporter, or event actor.
-|created_at, updated_at, closed_at|Date|Real Date values, so they can be
compared and formatted without a Select Values transform. A timestamp that
cannot be parsed becomes `null` rather than failing the row; the original text
stays in `raw_json`.
|url|String|Link to the item on the provider.
-|body|String|Commit message, issue or comment body, or the previous path for a
renamed file.
-|sha|String|Commit hash.
-|source_branch, target_branch|String|Pull or merge request branches.
-|merged|String|`Y` or `N`.
-|raw_json|String|The full provider payload. Left out when **Include raw JSON
in output** is unchecked.
+|raw_json|String|The full provider payload, when **Include raw JSON in
output** is checked. Always the last field, so unchecking it never shifts the
fields in front of it.
|===
+=== COMMITS
+
+`sha`, `title` (message subject), `body` (full message), `author`,
`author_email`, `author_login`, `committer`, `committer_email`, `created_at`,
`is_merge`.
+
+A commit carries two idents. `author` and `author_email` are who wrote the
change; `committer` and `committer_email` are who applied it, and the two
differ after a rebase, a squash, a cherry-pick or a merge made through a
provider's web UI. `author_login` is the provider account the ident was matched
to, which is a different thing again: `author` is whatever the person put in
their `git config`, `author_login` is who they are on the platform, and it is
the only one of the three that reli [...]
+
+`is_merge` is a Boolean, true when the commit has more than one parent, which
is what you want to filter out of contributor statistics. It is not related to
`merged` on a pull request: `is_merge` says the commit *is* a merge, `merged`
says a pull request *was* merged. The two never appear on the same row.
+
+=== COMMIT_FILES
+
+`sha`, `title` (file path), `state` (change type), `body` (previous path of a
rename or copy), `author`, `author_email`, `author_login`, `created_at`.
+
+=== ISSUES
+
+`number`, `title`, `state`, `body`, `author`, `author_login`, `labels`,
`assignees`, `created_at`, `updated_at`, `closed_at`.
+
+Issues identify people by account rather than by e-mail, so there is no
`author_email`. `labels` and `assignees` are comma-separated lists, flattened
from the provider's array. Bitbucket has no labels and carries a single
assignee rather than a list.
+
+=== PULL_REQUESTS
+
+The `ISSUES` fields, plus `source_branch`, `target_branch`, `merged` and
`merged_at`.
+
+`merged` is a Boolean; `merged_at` is when it happened, which is not the same
as `closed_at` and is what a lead-time or cycle-time calculation needs.
Bitbucket does not report a merge time.
+
+=== ISSUE_COMMENTS, PR_COMMENTS
+
+`number` (the parent issue or pull request), `title`, `state`, `body`,
`author`, `author_login`, `created_at`, `updated_at`.
+
+For these two types `title` and `state` carry the event type and event detail
rather than the issue title and state.
+
+=== ISSUE_EVENTS
+
+`number`, `title` (event type), `state` (event detail), `body`, `author`,
`author_login`, `created_at`.
+
+=== Dates
+
+`created_at`, `updated_at`, `closed_at` and `merged_at` are real Date values,
so they can be compared and formatted without a Select Values transform. A
timestamp that cannot be parsed becomes `null` rather than failing the row; the
original text stays in `raw_json`.
+
+
Providers format timestamps differently: GitHub uses `2026-05-01T12:00:00Z`,
GitLab adds milliseconds and Bitbucket uses microsecond precision. All of them
are parsed to the same instant.
=== Reading every page
@@ -125,6 +161,10 @@ That is the right setting for a full extract, but it hands
control of the reques
Set **Source** to `LOCAL` and point **Local repository path** at the folder
that contains `.git`. No connection or token is needed.
+Commit rows carry both Git idents in full: `author` and `author_email` from
the author ident, `committer` and `committer_email` from the committer ident,
and `is_merge` from the parent count. `author_login` stays empty, because a
clone on disk has no provider accounts to match idents against. All of it is
repeated in `raw_json`.
+
+That is enough for a contributor list: group on `author` and `author_email`,
count the rows, and filter on `is_merge` being false to leave merge commits out
of the totals.
+
`COMMIT_FILES` emits one row per changed file per commit, with the file path
in `title` and the change type (`added`, `modified`, `deleted`, `renamed`,
`copied`) in `state`. Rename and copy detection is on, so moving a file
produces a single `renamed` row carrying its previous path in `body` rather
than a `deleted` row plus an `added` row. Merge commits are compared against
their first parent.
== Issue comments and events
@@ -143,8 +183,6 @@ On GitHub both come from one repository-wide feed and are
told apart by the comm
NOTE: Gitea and Forgejo do not support `ISSUE_COMMENTS`, `PR_COMMENTS` or
`ISSUE_EVENTS` yet; the transform reports that rather than returning an empty
result.
-For these two types `title` and `state` carry the event type and event detail
rather than the issue title and state, and `number` identifies the parent issue.
-
The GitHub comment feed returns comments without their parent issue, so
`title` and `state` are empty for GitHub `ISSUE_COMMENTS` rows. That feed also
ignores **State**; use **Since** to limit it by comment time. GitLab and
Bitbucket walk issues one at a time and do fill both fields.
The row cap applies to the rows that come out, not to the number of issues
scanned.
diff --git
a/integration-tests/transforms/0112-git-input-github-pull-requests.hpl
b/integration-tests/transforms/0112-git-input-github-pull-requests.hpl
index e6ec53161b..3525d8ff43 100644
--- a/integration-tests/transforms/0112-git-input-github-pull-requests.hpl
+++ b/integration-tests/transforms/0112-git-input-github-pull-requests.hpl
@@ -249,11 +249,11 @@ limitations under the License.
<negated>N</negated>
<operator>AND</operator>
<leftvalue>merged</leftvalue>
- <function>IN LIST</function>
+ <function>IS NOT NULL</function>
<value>
<name>constant</name>
<type>String</type>
- <text>Y;N</text>
+ <text/>
<length>-1</length>
<precision>-1</precision>
<isnull>N</isnull>
diff --git
a/plugins/misc/git/src/main/java/org/apache/hop/git/pipeline/transforms/gitinput/GitInput.java
b/plugins/misc/git/src/main/java/org/apache/hop/git/pipeline/transforms/gitinput/GitInput.java
index 8de215f531..f1f4ebd585 100644
---
a/plugins/misc/git/src/main/java/org/apache/hop/git/pipeline/transforms/gitinput/GitInput.java
+++
b/plugins/misc/git/src/main/java/org/apache/hop/git/pipeline/transforms/gitinput/GitInput.java
@@ -99,7 +99,7 @@ public class GitInput extends BaseTransform<GitInputMeta,
GitInputData> {
if (data.reader != null && data.reader.hasNext()) {
GitResourceRecord record = data.reader.next();
- Object[] outputRow = record.toRow(meta.isIncludeRawJson());
+ Object[] outputRow = record.toRow(data.resourceType,
meta.isIncludeRawJson());
incrementLinesInput();
putRow(data.outputRowMeta, outputRow);
return true;
diff --git
a/plugins/misc/git/src/main/java/org/apache/hop/git/pipeline/transforms/gitinput/GitInputMeta.java
b/plugins/misc/git/src/main/java/org/apache/hop/git/pipeline/transforms/gitinput/GitInputMeta.java
index 552f75df07..14a1452860 100644
---
a/plugins/misc/git/src/main/java/org/apache/hop/git/pipeline/transforms/gitinput/GitInputMeta.java
+++
b/plugins/misc/git/src/main/java/org/apache/hop/git/pipeline/transforms/gitinput/GitInputMeta.java
@@ -331,20 +331,27 @@ public class GitInputMeta extends
BaseTransformMeta<GitInput, GitInputData> {
IHopMetadataProvider metadataProvider)
throws HopTransformException {
- for (int i = 0; i < GitInputFields.fieldCount(includeRawJson); i++) {
+ // The layout depends on what is being read: a commit row has no labels or
branches, an issue
+ // row has no commit idents. An unparseable resource type falls back to
COMMITS so the dialog
+ // can still show a field list while the type is a variable that is not
set yet.
+ GitResourceType type;
+ try {
+ type = GitResourceType.fromStored(variables.resolve(resourceType));
+ } catch (IllegalArgumentException e) {
+ type = GitResourceType.COMMITS;
+ }
+
+ for (GitInputFields.Field field : GitInputFields.layout(type,
includeRawJson)) {
IValueMeta valueMeta;
try {
- valueMeta =
- ValueMetaFactory.createValueMeta(
- GitInputFields.FIELD_NAMES[i], GitInputFields.FIELD_TYPES[i]);
+ valueMeta = ValueMetaFactory.createValueMeta(field.getFieldName(),
field.getType());
} catch (HopPluginException e) {
throw new HopTransformException(
- BaseMessages.getString(
- PKG, "GitInputMeta.Error.CreateValueMeta",
GitInputFields.FIELD_NAMES[i]),
+ BaseMessages.getString(PKG, "GitInputMeta.Error.CreateValueMeta",
field.getFieldName()),
e);
}
- if (GitInputFields.FIELD_LENGTHS[i] > 0) {
- valueMeta.setLength(GitInputFields.FIELD_LENGTHS[i]);
+ if (field.getLength() > 0) {
+ valueMeta.setLength(field.getLength());
}
valueMeta.setOrigin(name);
rowMeta.addValueMeta(valueMeta);
diff --git
a/plugins/misc/git/src/main/java/org/apache/hop/git/provider/BitbucketResourceClient.java
b/plugins/misc/git/src/main/java/org/apache/hop/git/provider/BitbucketResourceClient.java
index 2c7b39f638..975bac6f8c 100644
---
a/plugins/misc/git/src/main/java/org/apache/hop/git/provider/BitbucketResourceClient.java
+++
b/plugins/misc/git/src/main/java/org/apache/hop/git/provider/BitbucketResourceClient.java
@@ -21,6 +21,8 @@ import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
import org.apache.hop.core.exception.HopException;
import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
@@ -29,6 +31,9 @@ import org.json.simple.parser.ParseException;
class BitbucketResourceClient implements GitResourceClient {
+ /** Matches the e-mail inside a raw Git ident such as {@code Ada Lovelace
<[email protected]>}. */
+ private static final Pattern RAW_IDENT_EMAIL = Pattern.compile("<([^>]*)>");
+
private static final String ACCEPT = "application/json";
private static final String PROVIDER = "bitbucket";
private static final int BITBUCKET_MAX_PAGE_LEN = 100;
@@ -314,26 +319,23 @@ class BitbucketResourceClient implements
GitResourceClient {
case COMMITS -> {
JSONObject author = (JSONObject) json.get("author");
JSONObject user = author != null ? (JSONObject) author.get("user") :
null;
- yield new GitResourceRecord(
- PROVIDER,
- entityType,
- owner,
- repository,
- GitApiHttp.getString(json, "hash"),
- 0L,
- firstLine(renderedText(json, "message")),
- "",
- user != null ? GitApiHttp.getString(user, "display_name") : "",
- GitApiHttp.getString(json, "date"),
- "",
- "",
- extractLink(json, "html"),
- renderedText(json, "message"),
- GitApiHttp.getString(json, "hash"),
- "",
- "",
- "",
- rawJson);
+ yield GitResourceRecord.builder()
+ .provider(PROVIDER)
+ .entityType(entityType)
+ .repoOwner(owner)
+ .repoName(repository)
+ .id(GitApiHttp.getString(json, "hash"))
+ .sha(GitApiHttp.getString(json, "hash"))
+ .title(firstLine(renderedText(json, "message")))
+ .body(renderedText(json, "message"))
+ .author(user != null ? GitApiHttp.getString(user, "display_name")
: "")
+ .authorEmail(author != null ?
rawEmail(GitApiHttp.getString(author, "raw")) : "")
+ .authorLogin(user != null ? GitApiHttp.getString(user, "nickname")
: "")
+ .createdAt(GitApiHttp.getString(json, "date"))
+ .isMerge(GitJsonLists.mergeFlag(json, "parents"))
+ .url(extractLink(json, "html"))
+ .rawJson(rawJson)
+ .build();
}
case ISSUES, PULL_REQUESTS -> {
JSONObject reporter =
@@ -346,26 +348,28 @@ class BitbucketResourceClient implements
GitResourceClient {
resourceType == GitResourceType.PULL_REQUESTS
? (JSONObject) json.get("destination")
: null;
- yield new GitResourceRecord(
- PROVIDER,
- entityType,
- owner,
- repository,
- GitApiHttp.getString(json, "id"),
- GitApiHttp.getLong(json, "id"),
- GitApiHttp.getString(json, "title"),
- GitApiHttp.getString(json, "state"),
- reporter != null ? GitApiHttp.getString(reporter, "display_name")
: "",
- GitApiHttp.getString(json, "created_on"),
- GitApiHttp.getString(json, "updated_on"),
- "",
- extractLink(json, "html"),
- renderedText(json, "content"),
- "",
- branchName(source),
- branchName(destination),
- "MERGED".equalsIgnoreCase(GitApiHttp.getString(json, "state")) ?
"Y" : "N",
- rawJson);
+ yield GitResourceRecord.builder()
+ .provider(PROVIDER)
+ .entityType(entityType)
+ .repoOwner(owner)
+ .repoName(repository)
+ .id(GitApiHttp.getString(json, "id"))
+ .number(GitApiHttp.getLong(json, "id"))
+ .title(GitApiHttp.getString(json, "title"))
+ .state(GitApiHttp.getString(json, "state"))
+ .body(renderedText(json, "content"))
+ .author(reporter != null ? GitApiHttp.getString(reporter,
"display_name") : "")
+ .authorLogin(reporter != null ? GitApiHttp.getString(reporter,
"nickname") : "")
+ // Bitbucket has no labels, and carries a single assignee rather
than a list.
+ .assignees(GitJsonLists.names(json, "assignee", "display_name"))
+ .sourceBranch(branchName(source))
+ .targetBranch(branchName(destination))
+ .merged("MERGED".equalsIgnoreCase(GitApiHttp.getString(json,
"state")))
+ .createdAt(GitApiHttp.getString(json, "created_on"))
+ .updatedAt(GitApiHttp.getString(json, "updated_on"))
+ .url(extractLink(json, "html"))
+ .rawJson(rawJson)
+ .build();
}
case ISSUE_COMMENTS, PR_COMMENTS, ISSUE_EVENTS, COMMIT_FILES ->
throw new IllegalStateException();
@@ -452,6 +456,19 @@ class BitbucketResourceClient implements GitResourceClient
{
return query.toString();
}
+ /**
+ * Bitbucket reports a commit author as the raw Git ident, {@code Name
<[email protected]>}, and
+ * only resolves the linked account under {@code user}. The address is
pulled back out of the
+ * ident because an unlinked author has no {@code user} object at all.
+ */
+ private static String rawEmail(String raw) {
+ if (raw == null) {
+ return "";
+ }
+ Matcher matcher = RAW_IDENT_EMAIL.matcher(raw);
+ return matcher.find() ? matcher.group(1).trim() : "";
+ }
+
private static String firstLine(String message) {
if (message == null || message.isBlank()) {
return "";
diff --git
a/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitHubResourceClient.java
b/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitHubResourceClient.java
index 9c071e2e11..f24e35d7a8 100644
---
a/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitHubResourceClient.java
+++
b/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitHubResourceClient.java
@@ -659,26 +659,29 @@ class GitHubResourceClient implements GitResourceClient {
case COMMITS -> {
JSONObject commit = (JSONObject) json.get("commit");
JSONObject author = commit != null ? (JSONObject) commit.get("author")
: null;
- yield new GitResourceRecord(
- PROVIDER,
- entityType,
- owner,
- repository,
- GitApiHttp.getString(json, "sha"),
- 0L,
- firstLine(GitApiHttp.getString(commit, "message")),
- "",
- author != null ? GitApiHttp.getString(author, "name") : "",
- author != null ? GitApiHttp.getString(author, "date") : "",
- "",
- "",
- GitApiHttp.getString(json, "html_url"),
- commit != null ? GitApiHttp.getString(commit, "message") : "",
- GitApiHttp.getString(json, "sha"),
- "",
- "",
- "",
- rawJson);
+ JSONObject committer = commit != null ? (JSONObject)
commit.get("committer") : null;
+ // The nested commit.author is the Git ident the commit was written
with; the top level
+ // author is the GitHub account it was matched to, which is absent for
an unmatched ident.
+ JSONObject authorAccount = (JSONObject) json.get("author");
+ yield GitResourceRecord.builder()
+ .provider(PROVIDER)
+ .entityType(entityType)
+ .repoOwner(owner)
+ .repoName(repository)
+ .id(GitApiHttp.getString(json, "sha"))
+ .sha(GitApiHttp.getString(json, "sha"))
+ .title(firstLine(GitApiHttp.getString(commit, "message")))
+ .body(commit != null ? GitApiHttp.getString(commit, "message") :
"")
+ .author(author != null ? GitApiHttp.getString(author, "name") : "")
+ .authorEmail(author != null ? GitApiHttp.getString(author,
"email") : "")
+ .authorLogin(authorAccount != null ?
GitApiHttp.getString(authorAccount, "login") : "")
+ .committer(committer != null ? GitApiHttp.getString(committer,
"name") : "")
+ .committerEmail(committer != null ?
GitApiHttp.getString(committer, "email") : "")
+ .createdAt(author != null ? GitApiHttp.getString(author, "date") :
"")
+ .isMerge(GitJsonLists.mergeFlag(json, "parents"))
+ .url(GitApiHttp.getString(json, "html_url"))
+ .rawJson(rawJson)
+ .build();
}
case ISSUES, PULL_REQUESTS -> {
JSONObject user = (JSONObject) json.get("user");
@@ -686,26 +689,31 @@ class GitHubResourceClient implements GitResourceClient {
resourceType == GitResourceType.PULL_REQUESTS ? (JSONObject)
json.get("head") : null;
JSONObject base =
resourceType == GitResourceType.PULL_REQUESTS ? (JSONObject)
json.get("base") : null;
- yield new GitResourceRecord(
- PROVIDER,
- entityType,
- owner,
- repository,
- GitApiHttp.getString(json, "node_id"),
- GitApiHttp.getLong(json, "number"),
- GitApiHttp.getString(json, "title"),
- GitApiHttp.getString(json, "state"),
- user != null ? GitApiHttp.getString(user, "login") : "",
- GitApiHttp.getString(json, "created_at"),
- GitApiHttp.getString(json, "updated_at"),
- GitApiHttp.getString(json, "closed_at"),
- GitApiHttp.getString(json, "html_url"),
- GitApiHttp.getString(json, "body"),
- "",
- head != null ? GitApiHttp.getString(head, "ref") : "",
- base != null ? GitApiHttp.getString(base, "ref") : "",
- mapGithubMerged(json),
- rawJson);
+ String login = user != null ? GitApiHttp.getString(user, "login") : "";
+ yield GitResourceRecord.builder()
+ .provider(PROVIDER)
+ .entityType(entityType)
+ .repoOwner(owner)
+ .repoName(repository)
+ .id(GitApiHttp.getString(json, "node_id"))
+ .number(GitApiHttp.getLong(json, "number"))
+ .title(GitApiHttp.getString(json, "title"))
+ .state(GitApiHttp.getString(json, "state"))
+ .body(GitApiHttp.getString(json, "body"))
+ .author(login)
+ .authorLogin(login)
+ .labels(GitJsonLists.names(json, "labels", "name"))
+ .assignees(GitJsonLists.names(json, "assignees", "login"))
+ .sourceBranch(head != null ? GitApiHttp.getString(head, "ref") :
"")
+ .targetBranch(base != null ? GitApiHttp.getString(base, "ref") :
"")
+ .merged(mapGithubMerged(json))
+ .mergedAt(GitApiHttp.getString(json, "merged_at"))
+ .createdAt(GitApiHttp.getString(json, "created_at"))
+ .updatedAt(GitApiHttp.getString(json, "updated_at"))
+ .closedAt(GitApiHttp.getString(json, "closed_at"))
+ .url(GitApiHttp.getString(json, "html_url"))
+ .rawJson(rawJson)
+ .build();
}
case ISSUE_COMMENTS, PR_COMMENTS, ISSUE_EVENTS, COMMIT_FILES ->
throw new IllegalStateException();
@@ -713,13 +721,12 @@ class GitHubResourceClient implements GitResourceClient {
}
/** GitHub list-pulls responses often omit {@code merged}; {@code merged_at}
is reliable. */
- static String mapGithubMerged(org.json.simple.JSONObject json) {
+ static boolean mapGithubMerged(org.json.simple.JSONObject json) {
Object merged = json.get("merged");
if (merged instanceof Boolean booleanValue) {
- return booleanValue ? "Y" : "N";
+ return booleanValue;
}
- String mergedAt = GitApiHttp.getString(json, "merged_at");
- return mergedAt.isBlank() ? "N" : "Y";
+ return !GitApiHttp.getString(json, "merged_at").isBlank();
}
private static JSONArray parseArray(String body) throws HopException {
diff --git
a/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitInputFields.java
b/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitInputFields.java
index a66805c30f..e9ea4d3f3f 100644
---
a/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitInputFields.java
+++
b/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitInputFields.java
@@ -17,67 +17,211 @@
package org.apache.hop.git.provider;
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+import lombok.Getter;
import org.apache.hop.core.row.IValueMeta;
-/** Describes the normalized output row layout shared by every provider. */
+/**
+ * The output row layout, which is chosen per {@link GitResourceType} rather
than shared by all of
+ * them.
+ *
+ * <p>A single row shared by every resource type would have to be the union of
everything any type
+ * can report, so a commit row would carry empty label and assignee columns
and an issue row would
+ * carry empty branch and merge columns. Each type therefore declares only the
fields it can
+ * actually fill.
+ *
+ * <p>Within a type the layout is still identical across providers, so a
pipeline written against
+ * GitHub keeps working when it is pointed at GitLab or Bitbucket. A field a
given provider does not
+ * report is an empty value, not a missing column.
+ */
public final class GitInputFields {
private GitInputFields() {}
- public static final String[] FIELD_NAMES = {
- "provider",
- "entity_type",
- "repo_owner",
- "repo_name",
- "id",
- "number",
- "title",
- "state",
- "author",
- "created_at",
- "updated_at",
- "closed_at",
- "url",
- "body",
- "sha",
- "source_branch",
- "target_branch",
- "merged",
- "raw_json"
- };
-
- public static final int[] FIELD_TYPES = {
- IValueMeta.TYPE_STRING, // provider
- IValueMeta.TYPE_STRING, // entity_type
- IValueMeta.TYPE_STRING, // repo_owner
- IValueMeta.TYPE_STRING, // repo_name
- IValueMeta.TYPE_STRING, // id
- IValueMeta.TYPE_INTEGER, // number
- IValueMeta.TYPE_STRING, // title
- IValueMeta.TYPE_STRING, // state
- IValueMeta.TYPE_STRING, // author
- IValueMeta.TYPE_DATE, // created_at
- IValueMeta.TYPE_DATE, // updated_at
- IValueMeta.TYPE_DATE, // closed_at
- IValueMeta.TYPE_STRING, // url
- IValueMeta.TYPE_STRING, // body
- IValueMeta.TYPE_STRING, // sha
- IValueMeta.TYPE_STRING, // source_branch
- IValueMeta.TYPE_STRING, // target_branch
- IValueMeta.TYPE_STRING, // merged
- IValueMeta.TYPE_STRING // raw_json
- };
-
- /** Display length per field; {@code -1} leaves the length unset. */
- public static final int[] FIELD_LENGTHS = {
- 32, 32, 128, 128, 64, -1, 512, 32, 128, -1, -1, -1, 512, -1, 64, 128, 128,
8, -1
- };
-
- /** Index of {@code raw_json} in {@link #FIELD_NAMES}. */
- public static final int RAW_JSON_FIELD_INDEX = FIELD_NAMES.length - 1;
-
- /** Number of output fields, which depends on whether {@code raw_json} is
included. */
- public static int fieldCount(boolean includeRawJson) {
- return includeRawJson ? FIELD_NAMES.length : FIELD_NAMES.length - 1;
+ /** A single output field: its name in the row, its Hop type, and its
display length. */
+ @Getter
+ public enum Field {
+ PROVIDER("provider", IValueMeta.TYPE_STRING, 32),
+ ENTITY_TYPE("entity_type", IValueMeta.TYPE_STRING, 32),
+ REPO_OWNER("repo_owner", IValueMeta.TYPE_STRING, 128),
+ REPO_NAME("repo_name", IValueMeta.TYPE_STRING, 128),
+ ID("id", IValueMeta.TYPE_STRING, 64),
+ NUMBER("number", IValueMeta.TYPE_INTEGER, -1),
+ TITLE("title", IValueMeta.TYPE_STRING, 512),
+ STATE("state", IValueMeta.TYPE_STRING, 32),
+ AUTHOR("author", IValueMeta.TYPE_STRING, 128),
+ AUTHOR_EMAIL("author_email", IValueMeta.TYPE_STRING, 256),
+ AUTHOR_LOGIN("author_login", IValueMeta.TYPE_STRING, 128),
+ COMMITTER("committer", IValueMeta.TYPE_STRING, 128),
+ COMMITTER_EMAIL("committer_email", IValueMeta.TYPE_STRING, 256),
+ LABELS("labels", IValueMeta.TYPE_STRING, 512),
+ ASSIGNEES("assignees", IValueMeta.TYPE_STRING, 512),
+ CREATED_AT("created_at", IValueMeta.TYPE_DATE, -1),
+ UPDATED_AT("updated_at", IValueMeta.TYPE_DATE, -1),
+ CLOSED_AT("closed_at", IValueMeta.TYPE_DATE, -1),
+ MERGED_AT("merged_at", IValueMeta.TYPE_DATE, -1),
+ URL("url", IValueMeta.TYPE_STRING, 512),
+ BODY("body", IValueMeta.TYPE_STRING, -1),
+ SHA("sha", IValueMeta.TYPE_STRING, 64),
+ SOURCE_BRANCH("source_branch", IValueMeta.TYPE_STRING, 128),
+ TARGET_BRANCH("target_branch", IValueMeta.TYPE_STRING, 128),
+ MERGED("merged", IValueMeta.TYPE_BOOLEAN, -1),
+ IS_MERGE("is_merge", IValueMeta.TYPE_BOOLEAN, -1),
+ RAW_JSON("raw_json", IValueMeta.TYPE_STRING, -1);
+
+ private final String fieldName;
+ private final int type;
+ private final int length;
+
+ Field(String fieldName, int type, int length) {
+ this.fieldName = fieldName;
+ this.type = type;
+ this.length = length;
+ }
+ }
+
+ /**
+ * The fields every type reports, in the order they lead each row. {@code
id} is the provider
+ * identifier and {@code url} the link back to the item.
+ */
+ private static final List<Field> IDENTITY =
+ List.of(Field.PROVIDER, Field.ENTITY_TYPE, Field.REPO_OWNER,
Field.REPO_NAME, Field.ID);
+
+ private static final Map<GitResourceType, List<Field>> LAYOUTS =
+ new EnumMap<>(GitResourceType.class);
+
+ static {
+ // A commit has two idents, author and committer, which differ on a
rebase, a squash, a
+ // cherry-pick or a merge made through a provider's web UI.
+ LAYOUTS.put(
+ GitResourceType.COMMITS,
+ concat(
+ Field.SHA,
+ Field.TITLE,
+ Field.BODY,
+ Field.AUTHOR,
+ Field.AUTHOR_EMAIL,
+ Field.AUTHOR_LOGIN,
+ Field.COMMITTER,
+ Field.COMMITTER_EMAIL,
+ Field.CREATED_AT,
+ Field.IS_MERGE,
+ Field.URL));
+
+ // One row per changed file: title is the path, state the change type,
body the previous path
+ // of a rename or a copy.
+ LAYOUTS.put(
+ GitResourceType.COMMIT_FILES,
+ concat(
+ Field.SHA,
+ Field.TITLE,
+ Field.STATE,
+ Field.BODY,
+ Field.AUTHOR,
+ Field.AUTHOR_EMAIL,
+ Field.AUTHOR_LOGIN,
+ Field.CREATED_AT,
+ Field.URL));
+
+ // Issues and pull requests identify people by account, so there is no
e-mail to report.
+ LAYOUTS.put(
+ GitResourceType.ISSUES,
+ concat(
+ Field.NUMBER,
+ Field.TITLE,
+ Field.STATE,
+ Field.BODY,
+ Field.AUTHOR,
+ Field.AUTHOR_LOGIN,
+ Field.LABELS,
+ Field.ASSIGNEES,
+ Field.CREATED_AT,
+ Field.UPDATED_AT,
+ Field.CLOSED_AT,
+ Field.URL));
+
+ LAYOUTS.put(
+ GitResourceType.PULL_REQUESTS,
+ concat(
+ Field.NUMBER,
+ Field.TITLE,
+ Field.STATE,
+ Field.BODY,
+ Field.AUTHOR,
+ Field.AUTHOR_LOGIN,
+ Field.LABELS,
+ Field.ASSIGNEES,
+ Field.SOURCE_BRANCH,
+ Field.TARGET_BRANCH,
+ Field.MERGED,
+ Field.MERGED_AT,
+ Field.CREATED_AT,
+ Field.UPDATED_AT,
+ Field.CLOSED_AT,
+ Field.URL));
+
+ // For a comment, number is the parent issue or pull request.
+ List<Field> comment =
+ concat(
+ Field.NUMBER,
+ Field.TITLE,
+ Field.STATE,
+ Field.BODY,
+ Field.AUTHOR,
+ Field.AUTHOR_LOGIN,
+ Field.CREATED_AT,
+ Field.UPDATED_AT,
+ Field.URL);
+ LAYOUTS.put(GitResourceType.ISSUE_COMMENTS, comment);
+ LAYOUTS.put(GitResourceType.PR_COMMENTS, comment);
+
+ // For an event, title is the event type and state its detail.
+ LAYOUTS.put(
+ GitResourceType.ISSUE_EVENTS,
+ concat(
+ Field.NUMBER,
+ Field.TITLE,
+ Field.STATE,
+ Field.BODY,
+ Field.AUTHOR,
+ Field.AUTHOR_LOGIN,
+ Field.CREATED_AT,
+ Field.URL));
+ }
+
+ private static List<Field> concat(Field... typeFields) {
+ return java.util.stream.Stream.concat(IDENTITY.stream(),
java.util.stream.Stream.of(typeFields))
+ .toList();
+ }
+
+ /**
+ * The output fields for a resource type, in row order. {@code raw_json} is
appended last when it
+ * is requested, so dropping it never shifts the fields in front of it.
+ */
+ public static List<Field> layout(GitResourceType resourceType, boolean
includeRawJson) {
+ List<Field> fields = LAYOUTS.get(resourceType);
+ if (fields == null) {
+ throw new IllegalArgumentException("No output layout for resource type "
+ resourceType);
+ }
+ if (!includeRawJson) {
+ return fields;
+ }
+ return java.util.stream.Stream.concat(
+ fields.stream(), java.util.stream.Stream.of(Field.RAW_JSON))
+ .toList();
+ }
+
+ /** Number of output fields for a resource type. */
+ public static int fieldCount(GitResourceType resourceType, boolean
includeRawJson) {
+ return layout(resourceType, includeRawJson).size();
+ }
+
+ /** Field names for a resource type, for the dialog and for tests. */
+ public static String[] fieldNames(GitResourceType resourceType, boolean
includeRawJson) {
+ return layout(resourceType, includeRawJson).stream()
+ .map(Field::getFieldName)
+ .toArray(String[]::new);
}
}
diff --git
a/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitIssueActivityMapper.java
b/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitIssueActivityMapper.java
index dd1acc0651..2c287eae6d 100644
---
a/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitIssueActivityMapper.java
+++
b/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitIssueActivityMapper.java
@@ -38,26 +38,23 @@ final class GitIssueActivityMapper {
String url,
String body,
JSONObject rawJson) {
- return new GitResourceRecord(
- provider,
- commentType.getEntityType(),
- owner,
- repository,
- id,
- issueNumber,
- issueTitle,
- issueState,
- author,
- createdAt,
- updatedAt,
- "",
- url,
- body,
- "",
- "",
- "",
- "",
- rawJson.toJSONString());
+ return GitResourceRecord.builder()
+ .provider(provider)
+ .entityType(commentType.getEntityType())
+ .repoOwner(owner)
+ .repoName(repository)
+ .id(id)
+ .number(issueNumber)
+ .title(issueTitle)
+ .state(issueState)
+ .body(body)
+ .author(author)
+ .authorLogin(author)
+ .createdAt(createdAt)
+ .updatedAt(updatedAt)
+ .url(url)
+ .rawJson(rawJson.toJSONString())
+ .build();
}
static GitResourceRecord event(
@@ -74,25 +71,21 @@ final class GitIssueActivityMapper {
String createdAt,
String url,
JSONObject rawJson) {
- return new GitResourceRecord(
- provider,
- GitResourceType.ISSUE_EVENTS.getEntityType(),
- owner,
- repository,
- id,
- issueNumber,
- eventType,
- eventDetail,
- actor,
- createdAt,
- "",
- "",
- url,
- eventDetail,
- "",
- "",
- "",
- "",
- rawJson.toJSONString());
+ return GitResourceRecord.builder()
+ .provider(provider)
+ .entityType(GitResourceType.ISSUE_EVENTS.getEntityType())
+ .repoOwner(owner)
+ .repoName(repository)
+ .id(id)
+ .number(issueNumber)
+ .title(eventType)
+ .state(eventDetail)
+ .body(eventDetail)
+ .author(actor)
+ .authorLogin(actor)
+ .createdAt(createdAt)
+ .url(url)
+ .rawJson(rawJson.toJSONString())
+ .build();
}
}
diff --git
a/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitJsonLists.java
b/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitJsonLists.java
new file mode 100644
index 0000000000..41a6b5a6e9
--- /dev/null
+++
b/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitJsonLists.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.git.provider;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+
+/**
+ * Flattens the small JSON arrays that providers use for labels, assignees and
commit parents into
+ * the single output field the row carries.
+ */
+final class GitJsonLists {
+
+ /** Separator between the entries of a flattened list field. */
+ static final String SEPARATOR = ", ";
+
+ private GitJsonLists() {}
+
+ /**
+ * Joins a list of labels or assignees into one comma-separated value.
+ *
+ * <p>Providers disagree on the shape: GitHub and Gitea return objects to
read {@code nameKey}
+ * from, GitLab returns plain strings, and a single-valued field such as a
Bitbucket assignee is
+ * an object rather than an array. All three are accepted, and anything else
yields an empty value
+ * rather than a failed row.
+ */
+ static String names(JSONObject json, String arrayKey, String nameKey) {
+ if (json == null) {
+ return "";
+ }
+ Object value = json.get(arrayKey);
+ if (value instanceof JSONObject single) {
+ return GitApiHttp.getString(single, nameKey);
+ }
+ if (!(value instanceof JSONArray array)) {
+ return "";
+ }
+ List<String> names = new ArrayList<>(array.size());
+ for (Object entry : array) {
+ String name =
+ switch (entry) {
+ case JSONObject object -> GitApiHttp.getString(object, nameKey);
+ case String plain -> plain;
+ case null, default -> "";
+ };
+ if (!name.isBlank()) {
+ names.add(name);
+ }
+ }
+ return String.join(SEPARATOR, names);
+ }
+
+ /**
+ * True when a commit has more than one parent. Providers report the parents
as an array of
+ * objects ({@code parents}) or of ids ({@code parent_ids}); an absent array
means the payload
+ * does not say, which is reported as false rather than as a merge.
+ */
+ static boolean mergeFlag(JSONObject json, String arrayKey) {
+ if (json == null || !(json.get(arrayKey) instanceof JSONArray array)) {
+ return false;
+ }
+ return array.size() > 1;
+ }
+}
diff --git
a/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitLabResourceClient.java
b/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitLabResourceClient.java
index 9681f06b28..45edeb72d4 100644
---
a/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitLabResourceClient.java
+++
b/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitLabResourceClient.java
@@ -330,48 +330,51 @@ class GitLabResourceClient implements GitResourceClient {
return switch (resourceType) {
case COMMITS ->
- new GitResourceRecord(
- PROVIDER,
- entityType,
- owner,
- repository,
- GitApiHttp.getString(json, "id"),
- 0L,
- firstLine(GitApiHttp.getString(json, "message")),
- "",
- GitApiHttp.getString(json, "author_name"),
- GitApiHttp.getString(json, "created_at"),
- "",
- "",
- GitApiHttp.getString(json, "web_url"),
- GitApiHttp.getString(json, "message"),
- GitApiHttp.getString(json, "id"),
- "",
- "",
- "",
- rawJson);
+ GitResourceRecord.builder()
+ .provider(PROVIDER)
+ .entityType(entityType)
+ .repoOwner(owner)
+ .repoName(repository)
+ .id(GitApiHttp.getString(json, "id"))
+ .sha(GitApiHttp.getString(json, "id"))
+ .title(firstLine(GitApiHttp.getString(json, "message")))
+ .body(GitApiHttp.getString(json, "message"))
+ .author(GitApiHttp.getString(json, "author_name"))
+ .authorEmail(GitApiHttp.getString(json, "author_email"))
+ .committer(GitApiHttp.getString(json, "committer_name"))
+ .committerEmail(GitApiHttp.getString(json, "committer_email"))
+ .createdAt(GitApiHttp.getString(json, "created_at"))
+ .isMerge(GitJsonLists.mergeFlag(json, "parent_ids"))
+ .url(GitApiHttp.getString(json, "web_url"))
+ .rawJson(rawJson)
+ .build();
case ISSUES, PULL_REQUESTS -> {
JSONObject author = (JSONObject) json.get("author");
- yield new GitResourceRecord(
- PROVIDER,
- entityType,
- owner,
- repository,
- GitApiHttp.getString(json, "id"),
- GitApiHttp.getLong(json, "iid"),
- GitApiHttp.getString(json, "title"),
- GitApiHttp.getString(json, "state"),
- author != null ? GitApiHttp.getString(author, "username") : "",
- GitApiHttp.getString(json, "created_at"),
- GitApiHttp.getString(json, "updated_at"),
- GitApiHttp.getString(json, "closed_at"),
- GitApiHttp.getString(json, "web_url"),
- GitApiHttp.getString(json, "description"),
- "",
- GitApiHttp.getString(json, "source_branch"),
- GitApiHttp.getString(json, "target_branch"),
- "merged".equalsIgnoreCase(GitApiHttp.getString(json, "state")) ?
"Y" : "N",
- rawJson);
+ String login = author != null ? GitApiHttp.getString(author,
"username") : "";
+ yield GitResourceRecord.builder()
+ .provider(PROVIDER)
+ .entityType(entityType)
+ .repoOwner(owner)
+ .repoName(repository)
+ .id(GitApiHttp.getString(json, "id"))
+ .number(GitApiHttp.getLong(json, "iid"))
+ .title(GitApiHttp.getString(json, "title"))
+ .state(GitApiHttp.getString(json, "state"))
+ .body(GitApiHttp.getString(json, "description"))
+ .author(login)
+ .authorLogin(login)
+ .labels(GitJsonLists.names(json, "labels", "name"))
+ .assignees(GitJsonLists.names(json, "assignees", "username"))
+ .sourceBranch(GitApiHttp.getString(json, "source_branch"))
+ .targetBranch(GitApiHttp.getString(json, "target_branch"))
+ .merged("merged".equalsIgnoreCase(GitApiHttp.getString(json,
"state")))
+ .mergedAt(GitApiHttp.getString(json, "merged_at"))
+ .createdAt(GitApiHttp.getString(json, "created_at"))
+ .updatedAt(GitApiHttp.getString(json, "updated_at"))
+ .closedAt(GitApiHttp.getString(json, "closed_at"))
+ .url(GitApiHttp.getString(json, "web_url"))
+ .rawJson(rawJson)
+ .build();
}
case ISSUE_COMMENTS, PR_COMMENTS, ISSUE_EVENTS, COMMIT_FILES ->
throw new IllegalStateException();
diff --git
a/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitResourceRecord.java
b/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitResourceRecord.java
index b44265fa12..a0dae61a2f 100644
---
a/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitResourceRecord.java
+++
b/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitResourceRecord.java
@@ -17,10 +17,20 @@
package org.apache.hop.git.provider;
+import java.util.List;
+import lombok.Builder;
import lombok.Getter;
+import org.apache.hop.git.provider.GitInputFields.Field;
-/** Normalized row from a Git hosting provider API (commit, issue, or pull
request). */
+/**
+ * Normalized row from a Git hosting provider or a local clone.
+ *
+ * <p>A record carries every value any resource type can report; {@link
#toRow} then keeps the ones
+ * the type's layout actually asks for. A provider that does not report a
field simply never sets
+ * it, and the field arrives in the row as an empty value rather than as a
missing column.
+ */
@Getter
+@Builder
public class GitResourceRecord {
private final String provider;
@@ -32,85 +42,78 @@ public class GitResourceRecord {
private final String title;
private final String state;
private final String author;
+ private final String authorEmail;
+ private final String authorLogin;
+ private final String committer;
+ private final String committerEmail;
+ private final String labels;
+ private final String assignees;
private final String createdAt;
private final String updatedAt;
private final String closedAt;
+ private final String mergedAt;
private final String url;
private final String body;
private final String sha;
private final String sourceBranch;
private final String targetBranch;
- private final String merged;
+
+ /** Whether a pull request was merged. Null when the row is not a pull
request. */
+ private final Boolean merged;
+
+ /** Whether a commit has more than one parent. Null when the row is not a
commit. */
+ private final Boolean isMerge;
+
private final String rawJson;
- public GitResourceRecord(
- String provider,
- String entityType,
- String repoOwner,
- String repoName,
- String id,
- long number,
- String title,
- String state,
- String author,
- String createdAt,
- String updatedAt,
- String closedAt,
- String url,
- String body,
- String sha,
- String sourceBranch,
- String targetBranch,
- String merged,
- String rawJson) {
- this.provider = provider;
- this.entityType = entityType;
- this.repoOwner = repoOwner;
- this.repoName = repoName;
- this.id = id;
- this.number = number;
- this.title = title;
- this.state = state;
- this.author = author;
- this.createdAt = createdAt;
- this.updatedAt = updatedAt;
- this.closedAt = closedAt;
- this.url = url;
- this.body = body;
- this.sha = sha;
- this.sourceBranch = sourceBranch;
- this.targetBranch = targetBranch;
- this.merged = merged;
- this.rawJson = rawJson;
+ /** Builds the output row for a resource type, in the order {@link
GitInputFields} declares. */
+ public Object[] toRow(GitResourceType resourceType, boolean includeRawJson) {
+ List<Field> layout = GitInputFields.layout(resourceType, includeRawJson);
+ Object[] row = new Object[layout.size()];
+ for (int i = 0; i < layout.size(); i++) {
+ row[i] = valueOf(layout.get(i));
+ }
+ return row;
}
- public Object[] toRow() {
- return toRow(true);
+ /**
+ * The value for one field. Strings are never null, so a provider that does
not report a field
+ * yields a blank cell rather than a null; timestamps are parsed to real
Dates, and an unparseable
+ * one becomes null with the original text left in {@code raw_json}.
+ */
+ private Object valueOf(Field field) {
+ return switch (field) {
+ case PROVIDER -> text(provider);
+ case ENTITY_TYPE -> text(entityType);
+ case REPO_OWNER -> text(repoOwner);
+ case REPO_NAME -> text(repoName);
+ case ID -> text(id);
+ case NUMBER -> number;
+ case TITLE -> text(title);
+ case STATE -> text(state);
+ case AUTHOR -> text(author);
+ case AUTHOR_EMAIL -> text(authorEmail);
+ case AUTHOR_LOGIN -> text(authorLogin);
+ case COMMITTER -> text(committer);
+ case COMMITTER_EMAIL -> text(committerEmail);
+ case LABELS -> text(labels);
+ case ASSIGNEES -> text(assignees);
+ case CREATED_AT -> GitTimestamps.toDate(createdAt);
+ case UPDATED_AT -> GitTimestamps.toDate(updatedAt);
+ case CLOSED_AT -> GitTimestamps.toDate(closedAt);
+ case MERGED_AT -> GitTimestamps.toDate(mergedAt);
+ case URL -> text(url);
+ case BODY -> text(body);
+ case SHA -> text(sha);
+ case SOURCE_BRANCH -> text(sourceBranch);
+ case TARGET_BRANCH -> text(targetBranch);
+ case MERGED -> merged;
+ case IS_MERGE -> isMerge;
+ case RAW_JSON -> text(rawJson);
+ };
}
- public Object[] toRow(boolean includeRawJson) {
- Object[] row = new Object[GitInputFields.fieldCount(includeRawJson)];
- row[0] = provider;
- row[1] = entityType;
- row[2] = repoOwner;
- row[3] = repoName;
- row[4] = id;
- row[5] = number;
- row[6] = title;
- row[7] = state;
- row[8] = author;
- row[9] = GitTimestamps.toDate(createdAt);
- row[10] = GitTimestamps.toDate(updatedAt);
- row[11] = GitTimestamps.toDate(closedAt);
- row[12] = url;
- row[13] = body;
- row[14] = sha;
- row[15] = sourceBranch;
- row[16] = targetBranch;
- row[17] = merged;
- if (includeRawJson) {
- row[GitInputFields.RAW_JSON_FIELD_INDEX] = rawJson;
- }
- return row;
+ private static String text(String value) {
+ return value == null ? "" : value;
}
}
diff --git
a/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GiteaResourceClient.java
b/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GiteaResourceClient.java
index c8b6f78ca7..2db96855d0 100644
---
a/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GiteaResourceClient.java
+++
b/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GiteaResourceClient.java
@@ -191,49 +191,55 @@ class GiteaResourceClient implements GitResourceClient {
commit = json;
}
JSONObject author = (JSONObject) commit.get("author");
- yield new GitResourceRecord(
- PROVIDER,
- entityType,
- owner,
- repository,
- GitApiHttp.getString(json, "sha"),
- 0L,
- firstLine(GitApiHttp.getString(commit, "message")),
- "",
- author != null ? GitApiHttp.getString(author, "name") : "",
- author != null ? GitApiHttp.getString(author, "date") : "",
- "",
- "",
- GitApiHttp.getString(json, "html_url"),
- GitApiHttp.getString(commit, "message"),
- GitApiHttp.getString(json, "sha"),
- "",
- "",
- "",
- rawJson);
+ JSONObject committer = (JSONObject) commit.get("committer");
+ JSONObject authorAccount = (JSONObject) json.get("author");
+ yield GitResourceRecord.builder()
+ .provider(PROVIDER)
+ .entityType(entityType)
+ .repoOwner(owner)
+ .repoName(repository)
+ .id(GitApiHttp.getString(json, "sha"))
+ .sha(GitApiHttp.getString(json, "sha"))
+ .title(firstLine(GitApiHttp.getString(commit, "message")))
+ .body(GitApiHttp.getString(commit, "message"))
+ .author(author != null ? GitApiHttp.getString(author, "name") : "")
+ .authorEmail(author != null ? GitApiHttp.getString(author,
"email") : "")
+ .authorLogin(authorAccount != null ?
GitApiHttp.getString(authorAccount, "login") : "")
+ .committer(committer != null ? GitApiHttp.getString(committer,
"name") : "")
+ .committerEmail(committer != null ?
GitApiHttp.getString(committer, "email") : "")
+ .createdAt(author != null ? GitApiHttp.getString(author, "date") :
"")
+ .isMerge(GitJsonLists.mergeFlag(json, "parents"))
+ .url(GitApiHttp.getString(json, "html_url"))
+ .rawJson(rawJson)
+ .build();
}
case ISSUES, PULL_REQUESTS -> {
JSONObject user = (JSONObject) json.get("user");
- yield new GitResourceRecord(
- PROVIDER,
- entityType,
- owner,
- repository,
- GitApiHttp.getString(json, "id"),
- GitApiHttp.getLong(json, "number"),
- GitApiHttp.getString(json, "title"),
- GitApiHttp.getString(json, "state"),
- user != null ? GitApiHttp.getString(user, "login") : "",
- GitApiHttp.getString(json, "created_at"),
- GitApiHttp.getString(json, "updated_at"),
- GitApiHttp.getString(json, "closed_at"),
- GitApiHttp.getString(json, "html_url"),
- GitApiHttp.getString(json, "body"),
- "",
- nestedRef(json, "head"),
- nestedRef(json, "base"),
- Boolean.TRUE.equals(json.get("merged")) ? "Y" : "N",
- rawJson);
+ String login = user != null ? GitApiHttp.getString(user, "login") : "";
+ yield GitResourceRecord.builder()
+ .provider(PROVIDER)
+ .entityType(entityType)
+ .repoOwner(owner)
+ .repoName(repository)
+ .id(GitApiHttp.getString(json, "id"))
+ .number(GitApiHttp.getLong(json, "number"))
+ .title(GitApiHttp.getString(json, "title"))
+ .state(GitApiHttp.getString(json, "state"))
+ .body(GitApiHttp.getString(json, "body"))
+ .author(login)
+ .authorLogin(login)
+ .labels(GitJsonLists.names(json, "labels", "name"))
+ .assignees(GitJsonLists.names(json, "assignees", "login"))
+ .sourceBranch(nestedRef(json, "head"))
+ .targetBranch(nestedRef(json, "base"))
+ .merged(Boolean.TRUE.equals(json.get("merged")))
+ .mergedAt(GitApiHttp.getString(json, "merged_at"))
+ .createdAt(GitApiHttp.getString(json, "created_at"))
+ .updatedAt(GitApiHttp.getString(json, "updated_at"))
+ .closedAt(GitApiHttp.getString(json, "closed_at"))
+ .url(GitApiHttp.getString(json, "html_url"))
+ .rawJson(rawJson)
+ .build();
}
case ISSUE_COMMENTS, PR_COMMENTS, ISSUE_EVENTS, COMMIT_FILES ->
throw new IllegalStateException();
diff --git
a/plugins/misc/git/src/main/java/org/apache/hop/git/provider/LocalGitResourceClient.java
b/plugins/misc/git/src/main/java/org/apache/hop/git/provider/LocalGitResourceClient.java
index 99be900002..05dad6f5f6 100644
---
a/plugins/misc/git/src/main/java/org/apache/hop/git/provider/LocalGitResourceClient.java
+++
b/plugins/misc/git/src/main/java/org/apache/hop/git/provider/LocalGitResourceClient.java
@@ -33,6 +33,7 @@ import org.eclipse.jgit.diff.DiffEntry;
import org.eclipse.jgit.diff.DiffFormatter;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
@@ -389,7 +390,9 @@ public final class LocalGitResourceClient {
String oldPath = realPath(entry.getOldPath());
String newPath = realPath(entry.getNewPath());
String filePath = pickFilePath(entry.getChangeType(), oldPath, newPath);
- String author = commit.getAuthorIdent().getName();
+ PersonIdent authorIdent = commit.getAuthorIdent();
+ String author = identName(authorIdent);
+ String authorEmail = identEmail(authorIdent);
String createdAt =
Instant.ofEpochSecond(commit.getCommitTime()).toString();
String sha = commit.getName();
String url =
@@ -400,27 +403,25 @@ public final class LocalGitResourceClient {
raw.put("change_type", changeType);
raw.put("old_path", oldPath);
raw.put("new_path", newPath);
-
- return new GitResourceRecord(
- PROVIDER,
- GitResourceType.COMMIT_FILES.getEntityType(),
- info.getOwner(),
- info.getRepositoryName(),
- sha + ":" + filePath,
- 0L,
- filePath,
- changeType,
- author,
- createdAt,
- "",
- "",
- url,
- oldPath,
- sha,
- "",
- "",
- "",
- raw.toJSONString());
+ raw.put("author", author);
+ raw.put("author_email", authorEmail);
+
+ return GitResourceRecord.builder()
+ .provider(PROVIDER)
+ .entityType(GitResourceType.COMMIT_FILES.getEntityType())
+ .repoOwner(info.getOwner())
+ .repoName(info.getRepositoryName())
+ .id(sha + ":" + filePath)
+ .sha(sha)
+ .title(filePath)
+ .state(changeType)
+ .body(oldPath)
+ .author(author)
+ .authorEmail(authorEmail)
+ .createdAt(createdAt)
+ .url(url)
+ .rawJson(raw.toJSONString())
+ .build();
}
static String mapChangeType(DiffEntry.ChangeType changeType) {
@@ -443,9 +444,13 @@ public final class LocalGitResourceClient {
private static GitResourceRecord toCommitRecord(LocalRepositoryInfo info,
RevCommit commit) {
String message = commit.getFullMessage() == null ? "" :
commit.getFullMessage();
- String author = commit.getAuthorIdent().getName();
+ PersonIdent authorIdent = commit.getAuthorIdent();
+ PersonIdent committerIdent = commit.getCommitterIdent();
+ String author = identName(authorIdent);
+ String authorEmail = identEmail(authorIdent);
String createdAt =
Instant.ofEpochSecond(commit.getCommitTime()).toString();
String sha = commit.getName();
+ boolean isMerge = commit.getParentCount() > 1;
String url =
info.getRemoteUrl().isBlank() ? info.directory().getAbsolutePath() :
info.getRemoteUrl();
@@ -453,28 +458,30 @@ public final class LocalGitResourceClient {
raw.put("sha", sha);
raw.put("message", message);
raw.put("author", author);
+ raw.put("author_email", authorEmail);
+ raw.put("committer", identName(committerIdent));
+ raw.put("committer_email", identEmail(committerIdent));
raw.put("date", createdAt);
-
- return new GitResourceRecord(
- PROVIDER,
- GitResourceType.COMMITS.getEntityType(),
- info.getOwner(),
- info.getRepositoryName(),
- sha,
- 0L,
- firstLine(message),
- "",
- author,
- createdAt,
- "",
- "",
- url,
- message,
- sha,
- "",
- "",
- "",
- raw.toJSONString());
+ raw.put("parent_count", (long) commit.getParentCount());
+
+ return GitResourceRecord.builder()
+ .provider(PROVIDER)
+ .entityType(GitResourceType.COMMITS.getEntityType())
+ .repoOwner(info.getOwner())
+ .repoName(info.getRepositoryName())
+ .id(sha)
+ .sha(sha)
+ .title(firstLine(message))
+ .body(message)
+ .author(author)
+ .authorEmail(authorEmail)
+ .committer(identName(committerIdent))
+ .committerEmail(identEmail(committerIdent))
+ .createdAt(createdAt)
+ .isMerge(isMerge)
+ .url(url)
+ .rawJson(raw.toJSONString())
+ .build();
}
private static ObjectId resolveStart(Repository repo, String branch) throws
Exception {
@@ -516,6 +523,15 @@ public final class LocalGitResourceClient {
}
}
+ /** JGit leaves an unset ident, name or e-mail null; the output row wants a
blank cell instead. */
+ private static String identName(PersonIdent ident) {
+ return ident == null || ident.getName() == null ? "" : ident.getName();
+ }
+
+ private static String identEmail(PersonIdent ident) {
+ return ident == null || ident.getEmailAddress() == null ? "" :
ident.getEmailAddress();
+ }
+
private static String firstLine(String message) {
if (message == null || message.isBlank()) {
return "";
diff --git
a/plugins/misc/git/src/main/samples/transforms/git-input-github-pull-requests.hpl
b/plugins/misc/git/src/main/samples/transforms/git-input-github-pull-requests.hpl
index 14374ed5de..73e8cd4bc2 100644
---
a/plugins/misc/git/src/main/samples/transforms/git-input-github-pull-requests.hpl
+++
b/plugins/misc/git/src/main/samples/transforms/git-input-github-pull-requests.hpl
@@ -45,12 +45,20 @@ The 'github' connection has no token, so this uses the
anonymous GitHub API:
enough for a public repository, but limited to 60 requests per hour. Add a
personal access token for higher limits, or to read a private repository.
-Every provider returns the same row layout, so pointing this at a GitLab,
-Bitbucket, Forgejo or Gitea connection needs no other change.</note>
+Every provider returns the same row layout for a given resource type, so
+pointing this at a GitLab, Bitbucket, Forgejo or Gitea connection needs no
+other change. The layout is per resource type: a pull request row carries
+labels, assignees and merge fields, where a commit row carries the author
+and committer idents instead.
+
+labels and assignees are comma-separated lists flattened from the provider's
+arrays. author_login is the account the pull request was opened from, which
+is what joins it to commits and comments. merged_at is the merge time, which
+is not the same as closed_at.</note>
<xloc>64</xloc>
<yloc>48</yloc>
<width>560</width>
- <heigth>200</heigth>
+ <heigth>280</heigth>
<fontname>Noto Sans</fontname>
<fontsize>11</fontsize>
<fontbold>N</fontbold>
@@ -129,9 +137,18 @@ Bitbucket, Forgejo or Gitea connection needs no other
change.</note>
<field>
<name>author</name>
</field>
+ <field>
+ <name>author_login</name>
+ </field>
<field>
<name>state</name>
</field>
+ <field>
+ <name>labels</name>
+ </field>
+ <field>
+ <name>assignees</name>
+ </field>
<field>
<name>merged</name>
</field>
@@ -141,6 +158,9 @@ Bitbucket, Forgejo or Gitea connection needs no other
change.</note>
<field>
<name>closed_at</name>
</field>
+ <field>
+ <name>merged_at</name>
+ </field>
<field>
<name>source_branch</name>
</field>
diff --git
a/plugins/misc/git/src/test/java/org/apache/hop/git/pipeline/transforms/gitinput/GitInputMetaTest.java
b/plugins/misc/git/src/test/java/org/apache/hop/git/pipeline/transforms/gitinput/GitInputMetaTest.java
index d3f5f89042..728228c969 100644
---
a/plugins/misc/git/src/test/java/org/apache/hop/git/pipeline/transforms/gitinput/GitInputMetaTest.java
+++
b/plugins/misc/git/src/test/java/org/apache/hop/git/pipeline/transforms/gitinput/GitInputMetaTest.java
@@ -32,6 +32,8 @@ import org.apache.hop.core.row.IValueMeta;
import org.apache.hop.core.row.RowMeta;
import org.apache.hop.core.variables.Variables;
import org.apache.hop.core.xml.XmlHandler;
+import org.apache.hop.git.provider.GitInputFields;
+import org.apache.hop.git.provider.GitResourceType;
import org.apache.hop.metadata.serializer.xml.XmlMetadataUtil;
import org.apache.hop.pipeline.transform.TransformMeta;
import org.junit.jupiter.api.BeforeAll;
@@ -101,13 +103,14 @@ class GitInputMetaTest {
@Test
void timestampFieldsAreDatesNotStrings() throws Exception {
+ meta.setResourceType(GitResourceType.PULL_REQUESTS.name());
IRowMeta rowMeta = new RowMeta();
meta.getFields(rowMeta, "git", null, null, new Variables(), null);
- assertEquals(19, rowMeta.size());
assertEquals(IValueMeta.TYPE_DATE,
rowMeta.searchValueMeta("created_at").getType());
assertEquals(IValueMeta.TYPE_DATE,
rowMeta.searchValueMeta("updated_at").getType());
assertEquals(IValueMeta.TYPE_DATE,
rowMeta.searchValueMeta("closed_at").getType());
+ assertEquals(IValueMeta.TYPE_DATE,
rowMeta.searchValueMeta("merged_at").getType());
assertEquals(IValueMeta.TYPE_INTEGER,
rowMeta.searchValueMeta("number").getType());
assertEquals(IValueMeta.TYPE_STRING,
rowMeta.searchValueMeta("title").getType());
assertEquals("git", rowMeta.searchValueMeta("title").getOrigin());
@@ -119,10 +122,69 @@ class GitInputMetaTest {
IRowMeta rowMeta = new RowMeta();
meta.getFields(rowMeta, "git", null, null, new Variables(), null);
- assertEquals(18, rowMeta.size());
+ assertEquals(GitInputFields.fieldCount(GitResourceType.COMMITS, false),
rowMeta.size());
assertEquals(null, rowMeta.searchValueMeta("raw_json"));
}
+ @Test
+ void eachResourceTypeGetsItsOwnFieldLayout() throws Exception {
+ // A commit has idents and a merge flag but no labels, assignees or
branches; an issue is the
+ // other way round. A shared row would have to carry all of it and leave
most of it empty.
+ meta.setResourceType(GitResourceType.COMMITS.name());
+ IRowMeta commits = new RowMeta();
+ meta.getFields(commits, "git", null, null, new Variables(), null);
+
+ assertNotNull(commits.searchValueMeta("author_email"));
+ assertNotNull(commits.searchValueMeta("committer_email"));
+ assertNotNull(commits.searchValueMeta("is_merge"));
+ assertEquals(
+ IValueMeta.TYPE_BOOLEAN,
+ commits.searchValueMeta("is_merge").getType(),
+ "is_merge is a real Boolean, not a Y/N string");
+ assertNull(commits.searchValueMeta("labels"));
+ assertNull(commits.searchValueMeta("assignees"));
+ assertNull(commits.searchValueMeta("source_branch"));
+ assertNull(commits.searchValueMeta("closed_at"));
+
+ meta.setResourceType(GitResourceType.PULL_REQUESTS.name());
+ IRowMeta pullRequests = new RowMeta();
+ meta.getFields(pullRequests, "git", null, null, new Variables(), null);
+
+ assertNotNull(pullRequests.searchValueMeta("labels"));
+ assertNotNull(pullRequests.searchValueMeta("assignees"));
+ assertNotNull(pullRequests.searchValueMeta("merged_at"));
+ assertEquals(
+ IValueMeta.TYPE_BOOLEAN,
+ pullRequests.searchValueMeta("merged").getType(),
+ "merged is a real Boolean, not a Y/N string");
+ assertNotNull(pullRequests.searchValueMeta("source_branch"));
+ assertNull(pullRequests.searchValueMeta("committer"));
+ assertNull(pullRequests.searchValueMeta("is_merge"));
+
+ // Issues share the people and label fields with pull requests but never
the merge fields.
+ meta.setResourceType(GitResourceType.ISSUES.name());
+ IRowMeta issues = new RowMeta();
+ meta.getFields(issues, "git", null, null, new Variables(), null);
+
+ assertNotNull(issues.searchValueMeta("labels"));
+ assertNull(issues.searchValueMeta("merged"));
+ assertNull(issues.searchValueMeta("merged_at"));
+ }
+
+ @Test
+ void rawJsonIsAlwaysTheLastFieldWhateverTheType() throws Exception {
+ for (GitResourceType type : GitResourceType.values()) {
+ meta.setResourceType(type.name());
+ IRowMeta rowMeta = new RowMeta();
+ meta.getFields(rowMeta, "git", null, null, new Variables(), null);
+
+ assertEquals(
+ "raw_json",
+ rowMeta.getValueMeta(rowMeta.size() - 1).getName(),
+ "raw_json should be last for " + type);
+ }
+ }
+
@Test
void checkReportsMissingRemoteSettings() {
meta.setSource(GitInputSource.REMOTE.name());
diff --git
a/plugins/misc/git/src/test/java/org/apache/hop/git/pipeline/transforms/gitinput/GitInputTest.java
b/plugins/misc/git/src/test/java/org/apache/hop/git/pipeline/transforms/gitinput/GitInputTest.java
index 174dd7951b..a7d0aedd51 100644
---
a/plugins/misc/git/src/test/java/org/apache/hop/git/pipeline/transforms/gitinput/GitInputTest.java
+++
b/plugins/misc/git/src/test/java/org/apache/hop/git/pipeline/transforms/gitinput/GitInputTest.java
@@ -35,6 +35,7 @@ import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.row.IRowMeta;
import org.apache.hop.core.variables.Variables;
import org.apache.hop.git.provider.GitInputFields;
+import org.apache.hop.git.provider.GitResourceType;
import org.apache.hop.pipeline.transforms.mock.TransformMockHelper;
import org.eclipse.jgit.api.Git;
import org.junit.jupiter.api.AfterEach;
@@ -236,7 +237,8 @@ class GitInputTest {
// drain
}
- assertEquals(GitInputFields.FIELD_NAMES.length - 1,
data.outputRowMeta.size());
+ assertEquals(
+ GitInputFields.fieldCount(GitResourceType.COMMITS, false),
data.outputRowMeta.size());
assertEquals(-1, data.outputRowMeta.indexOfValue("raw_json"));
}
diff --git
a/plugins/misc/git/src/test/java/org/apache/hop/git/provider/BitbucketResourceClientWireMockTest.java
b/plugins/misc/git/src/test/java/org/apache/hop/git/provider/BitbucketResourceClientWireMockTest.java
index a554c5a724..f885d8d5e6 100644
---
a/plugins/misc/git/src/test/java/org/apache/hop/git/provider/BitbucketResourceClientWireMockTest.java
+++
b/plugins/misc/git/src/test/java/org/apache/hop/git/provider/BitbucketResourceClientWireMockTest.java
@@ -108,7 +108,8 @@ class BitbucketResourceClientWireMockTest {
envelopeWithNext(
"{\"hash\":\"deadbeef\",\"message\":\"a
commit\\n\\nwith a body\","
+
"\"date\":\"2026-05-01T12:00:00.000000+00:00\","
- +
"\"author\":{\"user\":{\"display_name\":\"Ada Lovelace\"}},"
+ + "\"author\":{\"raw\":\"Ada Lovelace
<[email protected]>\","
+ + "\"user\":{\"display_name\":\"Ada
Lovelace\"}},"
+
"\"links\":{\"html\":{\"href\":\"https://bitbucket.org/team/repo/commits/deadbeef\"}}}"))));
wireMock.stubFor(
get(urlPathEqualTo("/repositories/team/repo/commits"))
@@ -124,10 +125,12 @@ class BitbucketResourceClientWireMockTest {
assertEquals("deadbeef", record.getSha());
assertEquals("a commit", record.getTitle());
assertEquals("Ada Lovelace", record.getAuthor());
+ assertEquals("[email protected]", record.getAuthorEmail());
// Bitbucket's microsecond precision must still parse to a Date.
- Object[] row = record.toRow(true);
- int createdAt = List.of(GitInputFields.FIELD_NAMES).indexOf("created_at");
+ Object[] row = record.toRow(GitResourceType.COMMITS, true);
+ int createdAt =
+ List.of(GitInputFields.fieldNames(GitResourceType.COMMITS,
true)).indexOf("created_at");
assertEquals(Date.from(Instant.parse("2026-05-01T12:00:00Z")),
row[createdAt]);
}
diff --git
a/plugins/misc/git/src/test/java/org/apache/hop/git/provider/GitHubResourceClientTest.java
b/plugins/misc/git/src/test/java/org/apache/hop/git/provider/GitHubResourceClientTest.java
index ab5aba1d3e..0eee3409ea 100644
---
a/plugins/misc/git/src/test/java/org/apache/hop/git/provider/GitHubResourceClientTest.java
+++
b/plugins/misc/git/src/test/java/org/apache/hop/git/provider/GitHubResourceClientTest.java
@@ -17,7 +17,8 @@
package org.apache.hop.git.provider;
-import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import org.json.simple.JSONObject;
import org.junit.jupiter.api.Test;
@@ -30,7 +31,7 @@ class GitHubResourceClientTest {
listResponse.put("merged_at", "2026-05-01T12:00:00Z");
listResponse.put("state", "closed");
- assertEquals("Y", GitHubResourceClient.mapGithubMerged(listResponse));
+ assertTrue(GitHubResourceClient.mapGithubMerged(listResponse));
}
@Test
@@ -41,8 +42,8 @@ class GitHubResourceClientTest {
notMerged.put("merged", Boolean.FALSE);
notMerged.put("merged_at", "2026-05-01T12:00:00Z");
- assertEquals("Y", GitHubResourceClient.mapGithubMerged(merged));
- assertEquals("N", GitHubResourceClient.mapGithubMerged(notMerged));
+ assertTrue(GitHubResourceClient.mapGithubMerged(merged));
+ assertFalse(GitHubResourceClient.mapGithubMerged(notMerged));
}
@Test
@@ -51,6 +52,6 @@ class GitHubResourceClientTest {
closedUnmerged.put("state", "closed");
closedUnmerged.put("closed_at", "2026-05-01T12:00:00Z");
- assertEquals("N", GitHubResourceClient.mapGithubMerged(closedUnmerged));
+ assertFalse(GitHubResourceClient.mapGithubMerged(closedUnmerged));
}
}
diff --git
a/plugins/misc/git/src/test/java/org/apache/hop/git/provider/GitHubResourceClientWireMockTest.java
b/plugins/misc/git/src/test/java/org/apache/hop/git/provider/GitHubResourceClientWireMockTest.java
index 61b3445882..22defb4443 100644
---
a/plugins/misc/git/src/test/java/org/apache/hop/git/provider/GitHubResourceClientWireMockTest.java
+++
b/plugins/misc/git/src/test/java/org/apache/hop/git/provider/GitHubResourceClientWireMockTest.java
@@ -24,7 +24,6 @@ import static
com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -109,7 +108,7 @@ class GitHubResourceClientWireMockTest {
+ message
+ "\",\"author\":{\"name\":\""
+ author
- + "\",\"date\":\""
+ + "\",\"email\":\"[email protected]\",\"date\":\""
+ date
+ "\"}}}";
}
@@ -129,6 +128,7 @@ class GitHubResourceClientWireMockTest {
assertEquals("a1", record.getSha());
assertEquals("first", record.getTitle());
assertEquals("Ada", record.getAuthor());
+ assertEquals("[email protected]", record.getAuthorEmail());
}
@Test
@@ -139,12 +139,11 @@ class GitHubResourceClientWireMockTest {
List<GitResourceRecord> records =
drain(client.openReader(GitResourceType.COMMITS, "apache", "hop",
options(1, 10)));
- Object[] row = records.get(0).toRow(true);
+ Object[] row = records.get(0).toRow(GitResourceType.COMMITS, true);
+ List<String> fieldNames =
List.of(GitInputFields.fieldNames(GitResourceType.COMMITS, true));
assertEquals(
- Date.from(Instant.parse("2026-05-01T12:00:00Z")),
- row[GitInputFields.FIELD_NAMES.length - 10]);
- assertEquals(Date.from(Instant.parse("2026-05-01T12:00:00Z")), row[9]);
- assertNull(row[10], "updated_at is absent for a commit");
+ Date.from(Instant.parse("2026-05-01T12:00:00Z")),
row[fieldNames.indexOf("created_at")]);
+ assertEquals(-1, fieldNames.indexOf("updated_at"), "a commit layout has no
updated_at column");
}
@Test
@@ -229,6 +228,79 @@ class GitHubResourceClientWireMockTest {
assertTrue(reader.getTruncationNote().contains("1,000 items"));
}
+ @Test
+ void pullRequestRowsCarryLabelsAssigneesAndMergeTime() throws Exception {
+ wireMock.stubFor(
+ get(urlPathEqualTo("/repos/apache/hop/pulls"))
+ .withQueryParam("page", equalTo("1"))
+ .willReturn(
+ aResponse()
+ .withStatus(200)
+ .withBody(
+ "[{\"node_id\":\"n7\",\"number\":7,\"title\":\"a pull
request\","
+ +
"\"state\":\"closed\",\"user\":{\"login\":\"ada\"},"
+ +
"\"labels\":[{\"name\":\"bug\"},{\"name\":\"needs review\"}],"
+ +
"\"assignees\":[{\"login\":\"grace\"},{\"login\":\"alan\"}],"
+ + "\"merged_at\":\"2026-05-02T09:30:00Z\","
+ +
"\"head\":{\"ref\":\"feature\"},\"base\":{\"ref\":\"main\"}}]")));
+ wireMock.stubFor(
+ get(urlPathEqualTo("/repos/apache/hop/pulls"))
+ .withQueryParam("page", equalTo("2"))
+ .willReturn(aResponse().withStatus(200).withBody("[]")));
+
+ List<GitResourceRecord> records =
+ drain(client.openReader(GitResourceType.PULL_REQUESTS, "apache",
"hop", options(1, 10)));
+
+ assertEquals(1, records.size());
+ GitResourceRecord record = records.get(0);
+ assertEquals("bug, needs review", record.getLabels());
+ assertEquals("grace, alan", record.getAssignees());
+ assertEquals("ada", record.getAuthorLogin());
+ assertTrue(record.getMerged());
+
+ List<String> fieldNames =
+ List.of(GitInputFields.fieldNames(GitResourceType.PULL_REQUESTS,
true));
+ Object[] row = record.toRow(GitResourceType.PULL_REQUESTS, true);
+ assertEquals(
+ Date.from(Instant.parse("2026-05-02T09:30:00Z")),
row[fieldNames.indexOf("merged_at")]);
+ assertEquals(-1, fieldNames.indexOf("committer"), "a pull request layout
has no committer");
+ }
+
+ @Test
+ void commitRowsSeparateTheGitIdentFromTheGithubAccount() throws Exception {
+ // commit.author is what the commit was signed with; the top-level author
is the matched
+ // account, and the two are routinely different people or absent
altogether.
+ wireMock.stubFor(
+ get(urlPathEqualTo("/repos/apache/hop/commits"))
+ .withQueryParam("page", equalTo("1"))
+ .willReturn(
+ aResponse()
+ .withStatus(200)
+ .withBody(
+ "[{\"sha\":\"a1\",\"author\":{\"login\":\"ada-gh\"},"
+ +
"\"parents\":[{\"sha\":\"p1\"},{\"sha\":\"p2\"}],"
+ + "\"commit\":{\"message\":\"a merge\","
+ +
"\"author\":{\"name\":\"Ada\",\"email\":\"[email protected]\","
+ + "\"date\":\"2026-05-01T12:00:00Z\"},"
+ + "\"committer\":{\"name\":\"GitHub\","
+ + "\"email\":\"[email protected]\"}}}]")));
+ wireMock.stubFor(
+ get(urlPathEqualTo("/repos/apache/hop/commits"))
+ .withQueryParam("page", equalTo("2"))
+ .willReturn(aResponse().withStatus(200).withBody("[]")));
+
+ List<GitResourceRecord> records =
+ drain(client.openReader(GitResourceType.COMMITS, "apache", "hop",
options(1, 10)));
+
+ GitResourceRecord record = records.get(0);
+ assertEquals("Ada", record.getAuthor());
+ assertEquals("[email protected]", record.getAuthorEmail());
+ assertEquals("ada-gh", record.getAuthorLogin());
+ assertEquals("GitHub", record.getCommitter());
+ assertEquals("[email protected]", record.getCommitterEmail());
+ assertTrue(record.getIsMerge(), "two parents is a merge");
+ }
+
@Test
void pullRequestsAreExcludedFromTheIssuesFeed() throws Exception {
wireMock.stubFor(
diff --git
a/plugins/misc/git/src/test/java/org/apache/hop/git/provider/GitJsonListsTest.java
b/plugins/misc/git/src/test/java/org/apache/hop/git/provider/GitJsonListsTest.java
new file mode 100644
index 0000000000..e049d6a630
--- /dev/null
+++
b/plugins/misc/git/src/test/java/org/apache/hop/git/provider/GitJsonListsTest.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.git.provider;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.json.simple.JSONObject;
+import org.json.simple.parser.JSONParser;
+import org.junit.jupiter.api.Test;
+
+class GitJsonListsTest {
+
+ private static JSONObject parse(String json) throws Exception {
+ return (JSONObject) new JSONParser().parse(json);
+ }
+
+ @Test
+ void joinsGithubStyleObjectArrays() throws Exception {
+ JSONObject json = parse("{\"labels\":[{\"name\":\"bug\"},{\"name\":\"needs
review\"}]}");
+ assertEquals("bug, needs review", GitJsonLists.names(json, "labels",
"name"));
+ }
+
+ @Test
+ void joinsGitlabStylePlainStringArrays() throws Exception {
+ JSONObject json = parse("{\"labels\":[\"bug\",\"needs review\"]}");
+ assertEquals("bug, needs review", GitJsonLists.names(json, "labels",
"name"));
+ }
+
+ @Test
+ void readsASingleValuedFieldThatIsNotAnArray() throws Exception {
+ // Bitbucket carries one assignee as an object rather than a list of them.
+ JSONObject json = parse("{\"assignee\":{\"display_name\":\"Ada
Lovelace\"}}");
+ assertEquals("Ada Lovelace", GitJsonLists.names(json, "assignee",
"display_name"));
+ }
+
+ @Test
+ void skipsBlankAndUnusableEntriesRatherThanFailingTheRow() throws Exception {
+ JSONObject json =
parse("{\"labels\":[{\"name\":\"bug\"},{\"other\":\"x\"},null,42,\"\"]}");
+ assertEquals("bug", GitJsonLists.names(json, "labels", "name"));
+ }
+
+ @Test
+ void anAbsentOrEmptyListIsAnEmptyValue() throws Exception {
+ assertEquals("", GitJsonLists.names(parse("{}"), "labels", "name"));
+ assertEquals("", GitJsonLists.names(parse("{\"labels\":[]}"), "labels",
"name"));
+ assertEquals("", GitJsonLists.names(null, "labels", "name"));
+ }
+
+ @Test
+ void moreThanOneParentIsAMerge() throws Exception {
+ assertTrue(
+ GitJsonLists.mergeFlag(
+ parse("{\"parents\":[{\"sha\":\"a\"},{\"sha\":\"b\"}]}"),
"parents"));
+
assertFalse(GitJsonLists.mergeFlag(parse("{\"parents\":[{\"sha\":\"a\"}]}"),
"parents"));
+ assertFalse(GitJsonLists.mergeFlag(parse("{\"parents\":[]}"), "parents"));
+ }
+
+ @Test
+ void anAbsentParentListIsNotReportedAsAMerge() throws Exception {
+ // The payload does not say; guessing "merge" here would silently corrupt
commit counts.
+ assertFalse(GitJsonLists.mergeFlag(parse("{}"), "parents"));
+
assertFalse(GitJsonLists.mergeFlag(parse("{\"parent_ids\":[\"a\",\"b\"]}"),
"parents"));
+ assertTrue(GitJsonLists.mergeFlag(parse("{\"parent_ids\":[\"a\",\"b\"]}"),
"parent_ids"));
+ }
+}
diff --git
a/plugins/misc/git/src/test/java/org/apache/hop/git/provider/GitLabResourceClientWireMockTest.java
b/plugins/misc/git/src/test/java/org/apache/hop/git/provider/GitLabResourceClientWireMockTest.java
index 0034d09760..8dff2c0de4 100644
---
a/plugins/misc/git/src/test/java/org/apache/hop/git/provider/GitLabResourceClientWireMockTest.java
+++
b/plugins/misc/git/src/test/java/org/apache/hop/git/provider/GitLabResourceClientWireMockTest.java
@@ -92,7 +92,7 @@ class GitLabResourceClientWireMockTest {
.withBody(
"[{\"id\":\"abc123\",\"title\":\"fix things\","
+ "\"message\":\"fix things\\n\\nwith detail\","
- + "\"author_name\":\"Ada\","
+ +
"\"author_name\":\"Ada\",\"author_email\":\"[email protected]\","
+ "\"created_at\":\"2026-05-01T12:00:00.000Z\","
+
"\"web_url\":\"https://gitlab.com/apache/hop/-/commit/abc123\"}]")));
wireMock.stubFor(
@@ -108,10 +108,12 @@ class GitLabResourceClientWireMockTest {
assertEquals("gitlab", record.getProvider());
assertEquals("abc123", record.getSha());
assertEquals("Ada", record.getAuthor());
+ assertEquals("[email protected]", record.getAuthorEmail());
// GitLab's millisecond form still has to reach the row as a real Date.
- Object[] row = record.toRow(true);
- int createdAt = List.of(GitInputFields.FIELD_NAMES).indexOf("created_at");
+ Object[] row = record.toRow(GitResourceType.COMMITS, true);
+ int createdAt =
+ List.of(GitInputFields.fieldNames(GitResourceType.COMMITS,
true)).indexOf("created_at");
assertEquals(Date.from(Instant.parse("2026-05-01T12:00:00Z")),
row[createdAt]);
}
@@ -142,7 +144,7 @@ class GitLabResourceClientWireMockTest {
assertEquals(42L, record.getNumber(), "GitLab numbers rows by iid, not
id");
assertEquals("feature", record.getSourceBranch());
assertEquals("main", record.getTargetBranch());
- assertEquals("Y", record.getMerged());
+ assertTrue(record.getMerged());
}
@Test
diff --git
a/plugins/misc/git/src/test/java/org/apache/hop/git/provider/GiteaResourceClientWireMockTest.java
b/plugins/misc/git/src/test/java/org/apache/hop/git/provider/GiteaResourceClientWireMockTest.java
index 4e00295612..702136b08f 100644
---
a/plugins/misc/git/src/test/java/org/apache/hop/git/provider/GiteaResourceClientWireMockTest.java
+++
b/plugins/misc/git/src/test/java/org/apache/hop/git/provider/GiteaResourceClientWireMockTest.java
@@ -91,7 +91,8 @@ class GiteaResourceClientWireMockTest {
.withBody(
"[{\"sha\":\"cafe01\",\"html_url\":\"https://codeberg.org/bart/putki/commit/cafe01\","
+ "\"commit\":{\"message\":\"a commit\","
- +
"\"author\":{\"name\":\"Ada\",\"date\":\"2026-05-01T12:00:00Z\"}}}]")));
+ +
"\"author\":{\"name\":\"Ada\",\"email\":\"[email protected]\","
+ + "\"date\":\"2026-05-01T12:00:00Z\"}}}]")));
wireMock.stubFor(
get(urlPathEqualTo("/repos/bart/putki/commits"))
.withQueryParam("page", equalTo("2"))
@@ -104,6 +105,7 @@ class GiteaResourceClientWireMockTest {
assertEquals("gitea", records.get(0).getProvider());
assertEquals("cafe01", records.get(0).getSha());
assertEquals("Ada", records.get(0).getAuthor());
+ assertEquals("[email protected]", records.get(0).getAuthorEmail());
}
@Test
diff --git
a/plugins/misc/git/src/test/java/org/apache/hop/git/provider/LocalGitResourceClientTest.java
b/plugins/misc/git/src/test/java/org/apache/hop/git/provider/LocalGitResourceClientTest.java
index 82141b2ec2..cbe845b345 100644
---
a/plugins/misc/git/src/test/java/org/apache/hop/git/provider/LocalGitResourceClientTest.java
+++
b/plugins/misc/git/src/test/java/org/apache/hop/git/provider/LocalGitResourceClientTest.java
@@ -177,11 +177,97 @@ class LocalGitResourceClientTest {
List<GitResourceRecord> records = readAll(GitResourceType.COMMITS);
- int createdAt = List.of(GitInputFields.FIELD_NAMES).indexOf("created_at");
- Object value = records.get(0).toRow(true)[createdAt];
+ int createdAt =
+ List.of(GitInputFields.fieldNames(GitResourceType.COMMITS,
true)).indexOf("created_at");
+ Object value = records.get(0).toRow(GitResourceType.COMMITS,
true)[createdAt];
assertInstanceOf(Date.class, value);
}
+ @Test
+ void commitRowsCarryTheAuthorEmail() throws Exception {
+ initRepoWithTwoCommits();
+
+ List<GitResourceRecord> records = readAll(GitResourceType.COMMITS);
+
+ int authorEmail =
+ List.of(GitInputFields.fieldNames(GitResourceType.COMMITS,
true)).indexOf("author_email");
+ for (GitResourceRecord record : records) {
+ assertEquals("tester", record.getAuthor());
+ assertEquals("[email protected]", record.getAuthorEmail());
+ assertEquals("[email protected]", record.toRow(GitResourceType.COMMITS,
true)[authorEmail]);
+ assertTrue(
+
record.getRawJson().contains("\"author_email\":\"[email protected]\""),
+ "raw_json should carry the e-mail too: " + record.getRawJson());
+ }
+ }
+
+ @Test
+ void commitFileRowsCarryTheAuthorEmail() throws Exception {
+ initRepoWithTwoCommits();
+
+ List<GitResourceRecord> records = readAll(GitResourceType.COMMIT_FILES);
+
+ assertFalse(records.isEmpty());
+ for (GitResourceRecord record : records) {
+ assertEquals("[email protected]", record.getAuthorEmail());
+ }
+ }
+
+ @Test
+ void commitRowsSeparateAuthorFromCommitterAndFlagMerges() throws Exception {
+ // A rebase, a squash or a web merge rewrites the committer but keeps the
original author.
+ writeFile("hello.txt", "v1");
+ try (Git git = Git.init().setDirectory(repoDir).call()) {
+ git.add().addFilepattern(".").call();
+ git.commit()
+ .setMessage("initial")
+ .setAuthor("Ada", "[email protected]")
+ .setCommitter("Release Bot", "[email protected]")
+ .call();
+ }
+
+ List<GitResourceRecord> records = readAll(GitResourceType.COMMITS);
+
+ GitResourceRecord record = records.get(0);
+ assertEquals("Ada", record.getAuthor());
+ assertEquals("[email protected]", record.getAuthorEmail());
+ assertEquals("Release Bot", record.getCommitter());
+ assertEquals("[email protected]", record.getCommitterEmail());
+ assertFalse(record.getIsMerge(), "a root commit has one parent at most");
+ }
+
+ @Test
+ void aMergeCommitIsFlaggedAsOne() throws Exception {
+ try (Git git = Git.init().setDirectory(repoDir).call()) {
+ writeFile("hello.txt", "v1");
+ git.add().addFilepattern(".").call();
+ git.commit().setMessage("initial").setAuthor("tester",
"[email protected]").call();
+ String main = git.getRepository().getBranch();
+
+ git.checkout().setCreateBranch(true).setName("side").call();
+ writeFile("side.txt", "side");
+ git.add().addFilepattern(".").call();
+ git.commit().setMessage("side work").setAuthor("tester",
"[email protected]").call();
+
+ git.checkout().setName(main).call();
+ writeFile("hello.txt", "v2");
+ git.add().addFilepattern(".").call();
+ git.commit().setMessage("main work").setAuthor("tester",
"[email protected]").call();
+
+ git.merge()
+ .include(git.getRepository().resolve("side"))
+ .setCommit(true)
+ .setMessage("merge side")
+ .call();
+ }
+
+ List<GitResourceRecord> records = readAll(GitResourceType.COMMITS);
+
+ long merges = records.stream().filter(r ->
Boolean.TRUE.equals(r.getIsMerge())).count();
+ assertEquals(1, merges, "exactly the merge commit should be flagged");
+ assertTrue(records.get(0).getIsMerge(), "the merge is the most recent
commit");
+ }
+
@Test
void closingAReaderEarlyReleasesTheRepository() throws Exception {
initRepoWithTwoCommits();